search_skills

This commit is contained in:
John Lancaster
2026-08-08 00:14:31 -05:00
parent 9be7c27410
commit 6ec12a100a
7 changed files with 196 additions and 16 deletions
+2
View File
@@ -60,6 +60,8 @@ For skills:
4. read `_manifest` when supporting material may be needed 4. read `_manifest` when supporting material may be needed
5. fetch only the supporting paths relevant to the task 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. For prompts, use the native MCP prompt APIs or their generic tool projection.
## Stability Policy ## Stability Policy
+6 -6
View File
@@ -16,7 +16,7 @@ Copilot interacts with MCP servers through independently exposed lanes:
2. resources attached as read-only context 2. resources attached as read-only context
3. server-provided prompts 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 ## VS Code Feature Coverage
@@ -51,7 +51,7 @@ For every skill, Copilot can discover:
2. `skill://<name>/_manifest` 2. `skill://<name>/_manifest`
3. `skill://<name>/{path*}` supporting-file template 3. `skill://<name>/{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 ## Resource Picker Availability
@@ -66,8 +66,8 @@ A successful `resources/list` response does not guarantee the picker appears in
For autonomous agents: For autonomous agents:
1. call `list_resources` 1. call `search_skills` with the task, capability, or technology
2. compare main skill names and descriptions 2. compare the bounded main-skill matches
3. call `read_resource` for one relevant `skill://<name>/SKILL.md` 3. call `read_resource` for one relevant `skill://<name>/SKILL.md`
4. read `_manifest` only if supporting detail may be needed 4. read `_manifest` only if supporting detail may be needed
5. read only selected supporting files 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: When a task matches a personal-mcp skill:
1. Prefer an already attached native skill resource. 1. Prefer an already attached native skill resource.
2. Otherwise call `list_resources` and select one `skill://<name>/SKILL.md` resource by description. 2. Otherwise call `search_skills` and select one `skill://<name>/SKILL.md` result by description.
3. Call `read_resource` for the selected skill and read `_manifest` only when supporting material is needed. 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. 4. Load at most two candidate main files and only the relevant supporting paths.
5. Reconcile guidance with the current repository before editing. 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. 1. Use `MCP: List Servers` to confirm the server is enabled.
2. Use `MCP: Browse Resources` to confirm native skill resources exist. 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`. 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. 5. Reload the VS Code window if the server is healthy but the resource or tool picker remains stale.
+3 -3
View File
@@ -53,15 +53,15 @@ These utilities operate directly on the native `skill://` contract and require n
In VS Code, skills can arrive through: In VS Code, skills can arrive through:
1. explicit attachment from `Add Context > MCP Resources` or `MCP: Browse Resources` 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 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: A reliable prompt is:
```text ```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 ## Thin Shim Pattern
+98 -3
View File
@@ -1,10 +1,15 @@
from __future__ import annotations from __future__ import annotations
import re
from typing import Annotated
from fastmcp import FastMCP from fastmcp import FastMCP
from mcp.types import CompletionArgument from mcp.types import CompletionArgument
from mcp.types import CompletionContext from mcp.types import CompletionContext
from mcp.types import Icon from mcp.types import Icon
from mcp.types import PromptReference 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 import create_prompts_provider
from personal_mcp.prompts.models import MarkdownPrompt 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. _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 Use prompts for parameterized workflows. For task-specific guidance, search skills, select one
skill://<name>/SKILL.md by description, and read its manifest only when supporting detail is needed. skill://<name>/SKILL.md result, and read its manifest only when supporting detail is needed. Read-only
Read-only compatibility tools expose the same resource catalog to clients without native resource access. compatibility tools expose the same resource catalog to clients without native resource access.
""" """
_SERVER_ICON = Icon( _SERVER_ICON = Icon(
src=( 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: def _register_components(mcp: FastMCP, registry: DocsRegistry) -> None:
@mcp.resource( @mcp.resource(
"resource://docs/{path*}", "resource://docs/{path*}",
@@ -57,6 +107,51 @@ def _register_components(mcp: FastMCP, registry: DocsRegistry) -> None:
def _register_resource_tools(mcp: FastMCP) -> 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( @mcp.tool(
name="list_resources", name="list_resources",
title="List Resources", title="List Resources",
+15 -4
View File
@@ -35,12 +35,23 @@ class TestMcpHttpEndpoints:
"""Covers MCP transport endpoint smoke behavior.""" """Covers MCP transport endpoint smoke behavior."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_exposes_no_tools(self, mcp_session_factory) -> None: async def test_tools_bridge_native_skill_resources(self, mcp_session_factory) -> None:
"""Ensures the server publishes only native resource and prompt surfaces.""" """Ensures tool-only clients can discover and read native skill resources."""
async with mcp_session_factory() as mcp_session: 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 @pytest.mark.asyncio
async def test_accepts_initialize_jsonrpc_request( async def test_accepts_initialize_jsonrpc_request(
+28
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import pytest import pytest
from mcp.types import PromptReference
pytestmark = pytest.mark.smoke pytestmark = pytest.mark.smoke
@@ -18,6 +19,21 @@ EXPECTED_PROMPTS = {
class TestMcpPromptSurface: class TestMcpPromptSurface:
"""Covers smoke-level MCP prompt discovery and retrieval paths.""" """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://<name>/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: class TestPromptDiscovery:
"""Covers MCP prompts/list behavior using native prompt objects.""" """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 {prompt.name for prompt in result.prompts} == EXPECTED_PROMPTS
assert all(prompt.description for prompt in result.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: class TestPromptResolution:
"""Covers MCP prompts/get behavior using native request and response objects.""" """Covers MCP prompts/get behavior using native request and response objects."""
+44
View File
@@ -10,6 +10,50 @@ pytestmark = pytest.mark.smoke
class TestMcpSkillsSurface: class TestMcpSkillsSurface:
"""Covers native skill resources over the HTTP MCP surface.""" """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: class TestResources:
"""Covers native skill resources, manifests, and file templates.""" """Covers native skill resources, manifests, and file templates."""