diff --git a/src/personal_mcp/docs/contracts/uris.md b/src/personal_mcp/docs/contracts/uris.md index 0ebb462..fcb69b1 100644 --- a/src/personal_mcp/docs/contracts/uris.md +++ b/src/personal_mcp/docs/contracts/uris.md @@ -60,6 +60,8 @@ For skills: 4. read `_manifest` when supporting material may be needed 5. fetch only the supporting paths relevant to the task +Tool-only agents may call `search_skills` instead of retrieving the complete resource list. Search results contain only provider-derived names, descriptions, and canonical main-resource URIs; skill content remains available exclusively through the native resource contract. + For prompts, use the native MCP prompt APIs or their generic tool projection. ## Stability Policy diff --git a/src/personal_mcp/docs/copilot.md b/src/personal_mcp/docs/copilot.md index 51593fd..1e42479 100644 --- a/src/personal_mcp/docs/copilot.md +++ b/src/personal_mcp/docs/copilot.md @@ -16,7 +16,7 @@ Copilot interacts with MCP servers through independently exposed lanes: 2. resources attached as read-only context 3. server-provided prompts -This server publishes skills as native `skill://` resources and prompts as native MCP prompt objects. It also exposes generic `list_resources` and `read_resource` tools for agents whose tool catalog does not include direct MCP resource operations. +This server publishes skills as native `skill://` resources and prompts as native MCP prompt objects. It also exposes `search_skills`, `list_resources`, and `read_resource` tools for agents whose tool catalog does not include direct MCP resource operations. ## VS Code Feature Coverage @@ -51,7 +51,7 @@ For every skill, Copilot can discover: 2. `skill:///_manifest` 3. `skill:///{path*}` supporting-file template -The main resource description comes from `SKILL.md`. The manifest discloses supporting paths, sizes, and SHA256 hashes. Native resources remain the only skill content and discovery contract; the generic tools delegate to that same resource surface rather than maintaining a parallel catalog. +The main resource description comes from `SKILL.md`. The manifest discloses supporting paths, sizes, and SHA256 hashes. Native resources remain the only skill content and discovery contract; the tools search or delegate to that same resource surface rather than maintaining a parallel catalog. ## Resource Picker Availability @@ -66,8 +66,8 @@ A successful `resources/list` response does not guarantee the picker appears in For autonomous agents: -1. call `list_resources` -2. compare main skill names and descriptions +1. call `search_skills` with the task, capability, or technology +2. compare the bounded main-skill matches 3. call `read_resource` for one relevant `skill:///SKILL.md` 4. read `_manifest` only if supporting detail may be needed 5. read only selected supporting files @@ -102,7 +102,7 @@ A repo-level instruction should name the native retrieval order and context budg When a task matches a personal-mcp skill: 1. Prefer an already attached native skill resource. -2. Otherwise call `list_resources` and select one `skill:///SKILL.md` resource by description. +2. Otherwise call `search_skills` and select one `skill:///SKILL.md` result by description. 3. Call `read_resource` for the selected skill and read `_manifest` only when supporting material is needed. 4. Load at most two candidate main files and only the relevant supporting paths. 5. Reconcile guidance with the current repository before editing. @@ -118,7 +118,7 @@ Prompts remain separate from skills. When the client supports MCP prompt APIs, u 1. Use `MCP: List Servers` to confirm the server is enabled. 2. Use `MCP: Browse Resources` to confirm native skill resources exist. -3. Confirm `list_resources` and `read_resource` appear in the chat tool picker when autonomous retrieval is required. +3. Confirm `search_skills` and `read_resource` appear in the chat tool picker when autonomous retrieval is required. 4. Restart the MCP server after changing skill files because production uses `reload=False`. 5. Reload the VS Code window if the server is healthy but the resource or tool picker remains stale. diff --git a/src/personal_mcp/docs/usage.md b/src/personal_mcp/docs/usage.md index 275bf8d..3e3cca6 100644 --- a/src/personal_mcp/docs/usage.md +++ b/src/personal_mcp/docs/usage.md @@ -53,15 +53,15 @@ These utilities operate directly on the native `skill://` contract and require n In VS Code, skills can arrive through: 1. explicit attachment from `Add Context > MCP Resources` or `MCP: Browse Resources` -2. the generic `list_resources` and `read_resource` tools for autonomous agents +2. the `search_skills` and `read_resource` tools for autonomous agents 3. a slash-command prompt that names a specific native skill URI -Instructions can steer retrieval, but they do not guarantee automatic resource attachment in every chat surface. When the deferred-tool catalog omits direct MCP resource operations, use the generic tools; they delegate to the native providers and do not duplicate skill metadata or content. +Instructions can steer retrieval, but they do not guarantee automatic resource attachment in every chat surface. When the deferred-tool catalog omits direct MCP resource operations, use the tools; search derives matches from native provider metadata and `read_resource` delegates to the provider without duplicating skill content. A reliable prompt is: ```text -Call list_resources and select the best matching skill://.../SKILL.md resource. Use read_resource for one selected skill, inspect its _manifest only if supporting detail is needed, and reconcile the guidance with this workspace. +Call search_skills with the task or capability and select the best matching skill://.../SKILL.md result. Use read_resource for one selected skill, inspect its _manifest only if supporting detail is needed, and reconcile the guidance with this workspace. ``` ## Thin Shim Pattern diff --git a/src/personal_mcp/mcp.py b/src/personal_mcp/mcp.py index 47ff492..b54c40a 100644 --- a/src/personal_mcp/mcp.py +++ b/src/personal_mcp/mcp.py @@ -1,10 +1,15 @@ from __future__ import annotations +import re +from typing import Annotated + from fastmcp import FastMCP from mcp.types import CompletionArgument from mcp.types import CompletionContext from mcp.types import Icon from mcp.types import PromptReference +from pydantic import BaseModel +from pydantic import Field from personal_mcp.prompts import create_prompts_provider from personal_mcp.prompts.models import MarkdownPrompt @@ -16,9 +21,9 @@ from personal_mcp.skills import create_skills_provider _SERVER_INSTRUCTIONS = """Personal development guidance exposed as native MCP resources and prompts. -Use prompts for parameterized workflows. For task-specific guidance, list resources, select one -skill:///SKILL.md by description, and read its manifest only when supporting detail is needed. -Read-only compatibility tools expose the same resource catalog to clients without native resource access. +Use prompts for parameterized workflows. For task-specific guidance, search skills, select one +skill:///SKILL.md result, and read its manifest only when supporting detail is needed. Read-only +compatibility tools expose the same resource catalog to clients without native resource access. """ _SERVER_ICON = Icon( src=( @@ -42,6 +47,51 @@ def _ro_annotations() -> dict[str, bool]: } +class SkillSearchResult(BaseModel): + """Metadata needed to select a native skill resource.""" + + name: str + description: str + uri: str + + +class SkillSearchResponse(BaseModel): + """Bounded skill matches for an agent search query.""" + + results: list[SkillSearchResult] + + +def _skill_search_score(name: str, description: str, query: str) -> int: + normalized_name = name.casefold().replace("-", " ") + normalized_description = description.casefold() + normalized_query = " ".join(query.casefold().split()) + terms = tuple(dict.fromkeys(re.findall(r"[a-z0-9]+", normalized_query))) + if not terms: + return 0 + + name_terms = set(normalized_name.split()) + description_terms = set(re.findall(r"[a-z0-9]+", normalized_description)) + score = 100 if normalized_query == normalized_name else 0 + matched_terms = 0 + for term in terms: + term_score = 0 + if term in name_terms: + term_score = 20 + elif term in normalized_name: + term_score = 10 + elif term in description_terms: + term_score = 4 + elif term in normalized_description: + term_score = 1 + if term_score: + matched_terms += 1 + score += term_score + + if matched_terms == len(terms): + score += 10 + return score + + def _register_components(mcp: FastMCP, registry: DocsRegistry) -> None: @mcp.resource( "resource://docs/{path*}", @@ -57,6 +107,51 @@ def _register_components(mcp: FastMCP, registry: DocsRegistry) -> None: def _register_resource_tools(mcp: FastMCP) -> None: + @mcp.tool( + name="search_skills", + title="Search Skills", + description=( + "Find task-specific skill guidance by capability, technology, problem, or workflow. " + "Returns skill metadata and canonical URIs only; use read_resource to load a selected result." + ), + tags={"search", "skills"}, + annotations=_ro_annotations(), + ) + async def search_skills( + query: Annotated[ + str, + Field( + min_length=2, + max_length=300, + description="Capability, technology, problem, or workflow to find.", + ), + ], + limit: Annotated[ + int, + Field(ge=1, le=10, description="Maximum number of matches to return."), + ] = 5, + ) -> SkillSearchResponse: + resources = await mcp.list_resources() + matches: list[tuple[int, SkillSearchResult]] = [] + for resource in resources: + uri = str(resource.uri) + if not uri.startswith("skill://") or not uri.endswith("/SKILL.md"): + continue + + name = uri.removeprefix("skill://").removesuffix("/SKILL.md") + description = resource.description or "" + score = _skill_search_score(name, description, query) + if score: + matches.append( + ( + score, + SkillSearchResult(name=name, description=description, uri=uri), + ) + ) + + matches.sort(key=lambda match: (-match[0], match[1].name)) + return SkillSearchResponse(results=[match[1] for match in matches[:limit]]) + @mcp.tool( name="list_resources", title="List Resources", diff --git a/tests/web/test_endpoint_connections.py b/tests/web/test_endpoint_connections.py index 25cb5d9..3763baa 100644 --- a/tests/web/test_endpoint_connections.py +++ b/tests/web/test_endpoint_connections.py @@ -35,12 +35,23 @@ class TestMcpHttpEndpoints: """Covers MCP transport endpoint smoke behavior.""" @pytest.mark.asyncio - async def test_exposes_no_tools(self, mcp_session_factory) -> None: - """Ensures the server publishes only native resource and prompt surfaces.""" + async def test_tools_bridge_native_skill_resources(self, mcp_session_factory) -> None: + """Ensures tool-only clients can discover and read native skill resources.""" async with mcp_session_factory() as mcp_session: - result = await mcp_session.list_tools() + tools_result = await mcp_session.list_tools() + list_result = await mcp_session.call_tool("list_resources") + read_result = await mcp_session.call_tool( + "read_resource", + {"uri": "skill://mcp-details/SKILL.md"}, + ) - assert result.tools == [] + assert {tool.name for tool in tools_result.tools} == { + "list_resources", + "read_resource", + "search_skills", + } + assert "skill://mcp-details/SKILL.md" in list_result.content[0].text + assert "# MCP Details" in read_result.content[0].text @pytest.mark.asyncio async def test_accepts_initialize_jsonrpc_request( diff --git a/tests/web/test_mcp_prompts.py b/tests/web/test_mcp_prompts.py index 1ba3588..a4764ac 100644 --- a/tests/web/test_mcp_prompts.py +++ b/tests/web/test_mcp_prompts.py @@ -1,6 +1,7 @@ from __future__ import annotations import pytest +from mcp.types import PromptReference pytestmark = pytest.mark.smoke @@ -18,6 +19,21 @@ EXPECTED_PROMPTS = { class TestMcpPromptSurface: """Covers smoke-level MCP prompt discovery and retrieval paths.""" + class TestServerMetadata: + """Covers client-facing identity and completion capabilities.""" + + @pytest.mark.asyncio + async def test_advertises_instructions_and_completions(self, mcp_session_factory) -> None: + """Ensures VS Code receives server guidance and completion support.""" + async with mcp_session_factory(initialize=False) as mcp_session: + result = await mcp_session.initialize() + + assert result.instructions is not None + assert "skill:///SKILL.md" in result.instructions + assert result.server_info.icons + assert result.server_info.icons[0].src.startswith("data:image/svg+xml;base64,") + assert result.capabilities.completions is not None + class TestPromptDiscovery: """Covers MCP prompts/list behavior using native prompt objects.""" @@ -29,6 +45,18 @@ class TestMcpPromptSurface: assert {prompt.name for prompt in result.prompts} == EXPECTED_PROMPTS assert all(prompt.description for prompt in result.prompts) + assert all(prompt.title for prompt in result.prompts) + + @pytest.mark.asyncio + async def test_completes_authored_prompt_choices(self, mcp_session_factory) -> None: + """Ensures finite prompt choices are available as IDE-style suggestions.""" + async with mcp_session_factory() as mcp_session: + result = await mcp_session.complete( + PromptReference(type="ref/prompt", name="authoring"), + {"name": "artifact_type", "value": "pr"}, + ) + + assert result.completion.values == ["prompt"] class TestPromptResolution: """Covers MCP prompts/get behavior using native request and response objects.""" diff --git a/tests/web/test_mcp_skills.py b/tests/web/test_mcp_skills.py index 7288e60..b6461bc 100644 --- a/tests/web/test_mcp_skills.py +++ b/tests/web/test_mcp_skills.py @@ -10,6 +10,50 @@ pytestmark = pytest.mark.smoke class TestMcpSkillsSurface: """Covers native skill resources over the HTTP MCP surface.""" + class TestCompatibilityTools: + """Covers metadata for tool-only MCP clients.""" + + @pytest.mark.asyncio + async def test_exposes_safe_human_readable_tools(self, mcp_session_factory) -> None: + """Ensures clients receive display titles and complete safety hints.""" + async with mcp_session_factory() as mcp_session: + result = await mcp_session.list_tools() + + tools = {tool.name: tool for tool in result.tools} + assert tools["search_skills"].title == "Search Skills" + assert tools["list_resources"].title == "List Resources" + assert tools["read_resource"].title == "Read Resource" + assert all(tool.annotations is not None for tool in tools.values()) + assert all(tool.annotations.read_only_hint for tool in tools.values() if tool.annotations is not None) + assert all(tool.annotations.idempotent_hint for tool in tools.values() if tool.annotations is not None) + assert all( + tool.annotations.open_world_hint is False for tool in tools.values() if tool.annotations is not None + ) + + @pytest.mark.asyncio + async def test_searches_skill_metadata_without_loading_content(self, mcp_session_factory) -> None: + """Ensures search returns bounded canonical skill pointers ranked by metadata.""" + async with mcp_session_factory() as mcp_session: + result = await mcp_session.call_tool( + "search_skills", + {"query": "FastMCP protocol", "limit": 1}, + ) + + assert result.is_error is False + assert result.structured_content == { + "results": [ + { + "name": "mcp-details", + "description": ( + "Reference hub for MCP and FastMCP source documentation links. Use when you need " + "authoritative protocol, SDK, transport, and deployment docs without loading broad " + "implementation guidance." + ), + "uri": "skill://mcp-details/SKILL.md", + } + ] + } + class TestResources: """Covers native skill resources, manifests, and file templates."""