Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/docs/mcp/skills-over-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ social:
## Compatibility

`fast-agent` implements the stable
[Skills Extension](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2640-skills-extension.md)
[Skills Extension](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx)
(`io.modelcontextprotocol/skills`) wire shapes for its local-copy installer.

It uses `skills/list` and `skills/get`, reading declared files with
`resources/read`.
`resources/read`.

When a connected MCP server advertises this capability, `fast-agent` shows it as
an MCP-backed skills registry. Opening `/skills registry` calls the paginated
Expand Down
16 changes: 11 additions & 5 deletions src/fast_agent/mcp/client_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,15 @@

from fast_agent.core.exceptions import ServerSessionTerminatedError
from fast_agent.mcp.skills_extension import (
DirectoryReadRequest,
DirectoryReadRequestParams,
GetSkillRequest,
GetSkillRequestParams,
GetSkillResult,
ListSkillsRequest,
ListSkillsRequestParams,
ListSkillsResult,
ReadResourceDirectoryRequest,
ReadResourceDirectoryRequestParams,
ReadResourceDirectoryResult,
)
from fast_agent.mcp.tool_result_metadata import set_url_elicitation_required_payload
from fast_agent.mcp.uri_security import SANITIZED_INLINE_RESOURCE_URI, is_file_uri
Expand Down Expand Up @@ -298,9 +299,14 @@ async def read_directory(
uri: str,
*,
cursor: str | None = None,
) -> ListResourcesResult:
request = DirectoryReadRequest(params=DirectoryReadRequestParams(uri=uri, cursor=cursor))
return await self._request(self.client.session.send_request(request, ListResourcesResult))
) -> ReadResourceDirectoryResult:
"""List a directory's direct children under the stable MCP Skills extension."""
request = ReadResourceDirectoryRequest(
params=ReadResourceDirectoryRequestParams(uri=uri, cursor=cursor)
)
return await self._request(
self.client.session.send_request(request, ReadResourceDirectoryResult)
)

async def _request(self, operation: Awaitable[T]) -> T:
try:
Expand Down
10 changes: 7 additions & 3 deletions src/fast_agent/mcp/mcp_aggregator.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,11 @@
from fast_agent.mcp.interfaces import ServerRegistryProtocol
from fast_agent.mcp.mcp_connection_manager import MCPConnectionManager, ServerConnection
from fast_agent.mcp.prompt_metadata import with_prompt_metadata
from fast_agent.mcp.skills_extension import GetSkillResult, ListSkillsResult
from fast_agent.mcp.skills_extension import (
GetSkillResult,
ListSkillsResult,
ReadResourceDirectoryResult,
)
from fast_agent.mcp.tool_execution_handler import NoOpToolExecutionHandler, ToolExecutionHandler
from fast_agent.mcp.tool_permission_handler import (
NoOpToolPermissionHandler,
Expand Down Expand Up @@ -3789,7 +3793,7 @@ async def read_directory(
*,
server_name: str | None = None,
cursor: str | None = None,
) -> ListResourcesResult:
) -> ReadResourceDirectoryResult:
"""List the direct children of a directory resource via SEP-2640.

Routes ``resources/directory/read`` only to servers that declared
Expand Down Expand Up @@ -3871,7 +3875,7 @@ async def _get_skill_from_server(self, server_name: str, uri: str) -> GetSkillRe

async def _read_directory_from_server(
self, server_name: str, uri: str, *, cursor: str | None = None
) -> ListResourcesResult:
) -> ReadResourceDirectoryResult:
"""Internal helper to call ``resources/directory/read`` on a server."""
return await self._execute_resource_read(
server_name,
Expand Down
19 changes: 14 additions & 5 deletions src/fast_agent/mcp/skills_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
PaginatedResult,
Request,
RequestParams,
Resource,
)
from pydantic import BaseModel, ConfigDict, Field

Expand All @@ -35,6 +36,7 @@ class SkillEntry(BaseModel):
uri: str = Field(alias="uri")
frontmatter: dict[str, Any] = Field(alias="frontmatter")
resources: list[SkillResource] | Literal["dynamic"] = Field(alias="resources")
meta: dict[str, Any] | None = Field(default=None, alias="_meta")


class ListSkillsRequestParams(PaginatedRequestParams):
Expand Down Expand Up @@ -75,16 +77,23 @@ class GetSkillResult(CacheableResult):
result_type: Literal["complete"] = Field(default="complete", alias="resultType")


class DirectoryReadRequestParams(PaginatedRequestParams):
"""Parameters for the SEP-2640 directory-read extension."""
class ReadResourceDirectoryRequestParams(PaginatedRequestParams):
"""Parameters for the SEP-2640 ``resources/directory/read`` method."""

uri: str = Field(alias="uri")


class DirectoryReadRequest(
Request[DirectoryReadRequestParams, Literal["resources/directory/read"]]
class ReadResourceDirectoryRequest(
Request[ReadResourceDirectoryRequestParams, Literal["resources/directory/read"]]
):
"""Request for the SEP-2640 directory-read extension."""

method: Literal["resources/directory/read"] = "resources/directory/read"
params: DirectoryReadRequestParams
params: ReadResourceDirectoryRequestParams


class ReadResourceDirectoryResult(PaginatedResult, CacheableResult):
"""The paginated, cacheable response to ``resources/directory/read``."""

resources: list[Resource] = Field(alias="resources")
result_type: Literal["complete"] = Field(default="complete", alias="resultType")
66 changes: 62 additions & 4 deletions tests/unit/fast_agent/mcp/test_client_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from mcp.shared.exceptions import MCPDeprecationWarning, MCPError
from mcp_types import (
INVALID_REQUEST,
LATEST_PROTOCOL_VERSION,
METHOD_NOT_FOUND,
BlobResourceContents,
EmbeddedResource,
Expand All @@ -23,7 +24,11 @@
from fast_agent.core.exceptions import ServerSessionTerminatedError
from fast_agent.mcp.client_callback_runtime import MCPClientCallbackRuntime
from fast_agent.mcp.client_connection import MCPClientConnection
from fast_agent.mcp.skills_extension import GetSkillResult, ListSkillsResult
from fast_agent.mcp.skills_extension import (
GetSkillResult,
ListSkillsResult,
ReadResourceDirectoryResult,
)
from fast_agent.mcp.tool_result_metadata import url_elicitation_required_payload
from fast_agent.mcp.uri_security import is_file_uri

Expand Down Expand Up @@ -70,7 +75,7 @@ async def __call__(self, request: httpx2.Request) -> httpx2.Response:
"jsonrpc": "2.0",
"id": payload["id"],
"result": {
"supportedVersions": ["2026-07-28"],
"supportedVersions": [LATEST_PROTOCOL_VERSION],
"capabilities": {},
"resultType": "complete",
},
Expand Down Expand Up @@ -139,6 +144,10 @@ async def __call__(self, request: httpx2.Request) -> httpx2.Response:


class SkillsResponseStreamableHTTPSimulator(StatefulStreamableHTTPSimulator):
def __init__(self) -> None:
super().__init__()
self.requests: list[tuple[str, dict[str, object]]] = []

async def __call__(self, request: httpx2.Request) -> httpx2.Response:
if request.method != "POST":
return await super().__call__(request)
Expand All @@ -153,6 +162,7 @@ async def __call__(self, request: httpx2.Request) -> httpx2.Response:
assert request.headers["mcp-session-id"] == self.session_id
params = payload["params"]
assert isinstance(params, dict)
self.requests.append((method, params))
skill = {
"uri": "skill://demo/SKILL.md",
"frontmatter": {"name": "demo", "description": "Demo skill"},
Expand All @@ -163,8 +173,30 @@ async def __call__(self, request: httpx2.Request) -> httpx2.Response:
"size": 42,
}
],
"_meta": {"example.com/provenance": "signed"},
}
if method == "skills/list":
if method == "resources/directory/read":
assert params["uri"] == "skill://demo/templates"
assert params["cursor"] == "dir-1"
result = {
"resultType": "complete",
"resources": [
{
"uri": "skill://demo/templates/invoice.md",
"name": "invoice.md",
"mimeType": "text/markdown",
},
{
"uri": "skill://demo/templates/regional",
"name": "regional",
"mimeType": "inode/directory",
},
],
"nextCursor": "dir-2",
"ttlMs": 30_000,
"cacheScope": "public",
}
elif method == "skills/list":
assert params["cursor"] == "page-1"
result = {
"resultType": "complete",
Expand Down Expand Up @@ -230,7 +262,7 @@ async def __call__(self, request: httpx2.Request) -> httpx2.Response:
"jsonrpc": "2.0",
"id": payload["id"],
"result": {
"supportedVersions": ["2026-07-28"],
"supportedVersions": [LATEST_PROTOCOL_VERSION],
"capabilities": {"tools": {"listChanged": False}},
"resultType": "complete",
},
Expand Down Expand Up @@ -582,6 +614,7 @@ async def test_skills_extension_requests_and_parses_results(
) as connection:
listed = await connection.list_skills(cursor="page-1")
skill = await connection.get_skill("skill://demo/SKILL.md")
directory = await connection.read_directory("skill://demo/templates", cursor="dir-1")

await http_client.aclose()

Expand All @@ -592,10 +625,35 @@ async def test_skills_extension_requests_and_parses_results(
assert isinstance(listed.skills[0].resources, list)
assert listed.skills[0].resources[0].digest == "sha256:" + "a" * 64
assert listed.skills[0].resources[0].size == 42
assert listed.skills[0].meta == {"example.com/provenance": "signed"}
assert isinstance(skill, GetSkillResult)
assert skill.skill.frontmatter["name"] == "demo"
assert skill.skill.meta == {"example.com/provenance": "signed"}
assert skill.ttl_ms == 30_000
assert skill.cache_scope == "private"
assert isinstance(directory, ReadResourceDirectoryResult)
assert directory.result_type == "complete"
assert [str(resource.uri) for resource in directory.resources] == [
"skill://demo/templates/invoice.md",
"skill://demo/templates/regional",
]
assert directory.resources[1].mime_type == "inode/directory"
assert directory.next_cursor == "dir-2"
assert directory.ttl_ms == 30_000
assert directory.cache_scope == "public"

assert [method for method, _ in simulator.requests] == [
"skills/list",
"skills/get",
"resources/directory/read",
]
if protocol_mode == "modern":
for _, params in simulator.requests:
meta = params["_meta"]
assert isinstance(meta, dict)
assert meta["io.modelcontextprotocol/protocolVersion"] == LATEST_PROTOCOL_VERSION
assert isinstance(meta["io.modelcontextprotocol/clientInfo"], dict)
assert isinstance(meta["io.modelcontextprotocol/clientCapabilities"], dict)


@pytest.mark.asyncio
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from mcp_types import (
CompleteResult,
Completion,
ListResourcesResult,
ResourceTemplate,
ServerCapabilities,
)
Expand All @@ -17,7 +16,12 @@
from fast_agent.event_progress import ProgressAction
from fast_agent.mcp.app_integrations import AppServerConfig
from fast_agent.mcp.mcp_aggregator import MCPAggregator
from fast_agent.mcp.skills_extension import GetSkillResult, ListSkillsResult, SkillEntry
from fast_agent.mcp.skills_extension import (
GetSkillResult,
ListSkillsResult,
ReadResourceDirectoryResult,
SkillEntry,
)

if TYPE_CHECKING:
from mcp.client import CacheMode
Expand Down Expand Up @@ -352,14 +356,18 @@ async def _execute_on_server(
assert method_name == "read_directory"
assert isinstance(method_args, dict)
calls.append(method_args)
return ListResourcesResult(resources=[], next_cursor="next")
return ReadResourceDirectoryResult(
resources=[], next_cursor="next", ttl_ms=30_000, cache_scope="public"
)

aggregator = _DirectoryAggregator(
server_names=["demo"], connection_persistence=False, context=Context()
)
if settings.get("directoryRead") is True:
result = await aggregator.read_directory("skill://demo", server_name="demo", cursor="page")
assert result.next_cursor == "next"
assert result.ttl_ms == 30_000
assert result.cache_scope == "public"
assert calls == [{"uri": "skill://demo", "cursor": "page"}]
else:
with pytest.raises(ValueError, match="directoryRead"):
Expand Down
Loading