From d75ef846e4cb571f7b0e41c8a4818c22034d48a1 Mon Sep 17 00:00:00 2001 From: olaservo Date: Mon, 19 Oct 2026 21:49:17 -0700 Subject: [PATCH 1/2] Align MCP skills wire models with the 2026-10-15 spec revision Follows modelcontextprotocol/ext-skills#157, which aligned the stable Skills page with the 2026-07-28 base schema. - SkillEntry accepts the optional `_meta` object. - resources/directory/read returns ReadResourceDirectoryResult, which extends CacheableResult, instead of ListResourcesResult. - Directory-read request types use the spec's names. - The connection test asserts the SDK stamps `params._meta` on skills/list, skills/get, and resources/directory/read. Co-Authored-By: Claude Fable 5.1 --- docs/docs/mcp/skills-over-mcp.md | 4 +- src/fast_agent/mcp/client_connection.py | 16 +++-- src/fast_agent/mcp/mcp_aggregator.py | 10 ++- src/fast_agent/mcp/skills_extension.py | 19 ++++-- .../fast_agent/mcp/test_client_connection.py | 61 ++++++++++++++++++- ...est_mcp_aggregator_resource_completions.py | 14 ++++- 6 files changed, 104 insertions(+), 20 deletions(-) diff --git a/docs/docs/mcp/skills-over-mcp.md b/docs/docs/mcp/skills-over-mcp.md index 716c25fc6..d8b9c9d1f 100644 --- a/docs/docs/mcp/skills-over-mcp.md +++ b/docs/docs/mcp/skills-over-mcp.md @@ -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 diff --git a/src/fast_agent/mcp/client_connection.py b/src/fast_agent/mcp/client_connection.py index d50d2fdde..2600bccff 100644 --- a/src/fast_agent/mcp/client_connection.py +++ b/src/fast_agent/mcp/client_connection.py @@ -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 @@ -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: diff --git a/src/fast_agent/mcp/mcp_aggregator.py b/src/fast_agent/mcp/mcp_aggregator.py index 82dd095f0..92570f94d 100644 --- a/src/fast_agent/mcp/mcp_aggregator.py +++ b/src/fast_agent/mcp/mcp_aggregator.py @@ -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, @@ -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 @@ -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, diff --git a/src/fast_agent/mcp/skills_extension.py b/src/fast_agent/mcp/skills_extension.py index 2991e6204..e0aeff474 100644 --- a/src/fast_agent/mcp/skills_extension.py +++ b/src/fast_agent/mcp/skills_extension.py @@ -13,6 +13,7 @@ PaginatedResult, Request, RequestParams, + Resource, ) from pydantic import BaseModel, ConfigDict, Field @@ -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): @@ -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") diff --git a/tests/unit/fast_agent/mcp/test_client_connection.py b/tests/unit/fast_agent/mcp/test_client_connection.py index 6006cdfc3..723fdff74 100644 --- a/tests/unit/fast_agent/mcp/test_client_connection.py +++ b/tests/unit/fast_agent/mcp/test_client_connection.py @@ -23,7 +23,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 @@ -139,6 +143,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) @@ -153,6 +161,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"}, @@ -163,8 +172,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", @@ -582,6 +613,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() @@ -592,10 +624,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"] == "2026-07-28" + assert isinstance(meta["io.modelcontextprotocol/clientInfo"], dict) + assert isinstance(meta["io.modelcontextprotocol/clientCapabilities"], dict) @pytest.mark.asyncio diff --git a/tests/unit/fast_agent/mcp/test_mcp_aggregator_resource_completions.py b/tests/unit/fast_agent/mcp/test_mcp_aggregator_resource_completions.py index 1188729d6..5e6f03145 100644 --- a/tests/unit/fast_agent/mcp/test_mcp_aggregator_resource_completions.py +++ b/tests/unit/fast_agent/mcp/test_mcp_aggregator_resource_completions.py @@ -7,7 +7,6 @@ from mcp_types import ( CompleteResult, Completion, - ListResourcesResult, ResourceTemplate, ServerCapabilities, ) @@ -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 @@ -352,7 +356,9 @@ 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() @@ -360,6 +366,8 @@ async def _execute_on_server( 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"): From 2a7263d8beac5bbfd7115ff24781780d4ff18dd4 Mon Sep 17 00:00:00 2001 From: Shaun Smith <1936278+evalstate@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:05:52 -0700 Subject: [PATCH 2/2] test: use LATEST_PROTOCOL_VERSION for negotiated protocol version in skills test --- tests/unit/fast_agent/mcp/test_client_connection.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/unit/fast_agent/mcp/test_client_connection.py b/tests/unit/fast_agent/mcp/test_client_connection.py index 723fdff74..c7f796cd7 100644 --- a/tests/unit/fast_agent/mcp/test_client_connection.py +++ b/tests/unit/fast_agent/mcp/test_client_connection.py @@ -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, @@ -74,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", }, @@ -261,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", }, @@ -650,7 +651,7 @@ async def test_skills_extension_requests_and_parses_results( for _, params in simulator.requests: meta = params["_meta"] assert isinstance(meta, dict) - assert meta["io.modelcontextprotocol/protocolVersion"] == "2026-07-28" + assert meta["io.modelcontextprotocol/protocolVersion"] == LATEST_PROTOCOL_VERSION assert isinstance(meta["io.modelcontextprotocol/clientInfo"], dict) assert isinstance(meta["io.modelcontextprotocol/clientCapabilities"], dict)