From 05ea7b5048f9110ed1487608620663832ad3bd5d Mon Sep 17 00:00:00 2001 From: vaibhav-patel Date: Sat, 20 Jun 2026 15:45:15 +0530 Subject: [PATCH] Python: support MCP skills of mcp-resource-template type Add support for the `mcp-resource-template` skill kind in the MCP skills source. Per the SEP-2640 binding of the Agent Skills Discovery v0.2.0 schema, an `mcp-resource-template` index entry omits `name` and carries an RFC 6570 URI template in `url` (e.g. `skill://docs/{product}/SKILL.md`) that resolves to a `SKILL.md` resource once its variables are bound. One template stands in for a family of skills, one per variable binding. Introduce `MCPSkillResourceTemplate`, which parses such an entry, exposes its template variables, and materializes a concrete `MCPSkill` from an explicit variable binding (RFC 6570 Level-1 simple string expansion, with values percent-encoded so they cannot inject extra path segments). `MCPSkillsSource` gains `get_resource_templates()`, which surfaces template entries separately from `get_skills()`. Concrete skills are not auto-materialized during discovery because the template variable values are not part of the index; callers bind them explicitly via `materialize()`. This keeps `get_skills()` behavior unchanged (template entries remain excluded from the concrete skill list). Addresses #6118. --- .../packages/core/agent_framework/__init__.py | 2 + .../packages/core/agent_framework/_skills.py | 235 +++++++++++++++++- .../core/tests/core/test_mcp_skills.py | 201 ++++++++++++++- 3 files changed, 433 insertions(+), 5 deletions(-) diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index 07516ad36b5..88f286dd8a6 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -191,6 +191,7 @@ InMemorySkillsSource, MCPSkill, MCPSkillResource, + MCPSkillResourceTemplate, MCPSkillsSource, Skill, SkillFrontmatter, @@ -473,6 +474,7 @@ "LocalEvaluator", "MCPSkill", "MCPSkillResource", + "MCPSkillResourceTemplate", "MCPSkillsSource", "MCPStdioTool", "MCPStreamableHTTPTool", diff --git a/python/packages/core/agent_framework/_skills.py b/python/packages/core/agent_framework/_skills.py index 91bdb619143..4e25c144fdc 100644 --- a/python/packages/core/agent_framework/_skills.py +++ b/python/packages/core/agent_framework/_skills.py @@ -3589,6 +3589,157 @@ def _compute_skill_root_uri(skill_md_uri: str) -> str: return skill_md_uri + "/" +# Matches a single RFC 6570 Level-1 expression: ``{var}``. The skill-discovery +# binding only uses simple string expansion, so operators (``+#./;?&``) and +# explode/prefix modifiers are intentionally unsupported; such expressions are +# treated as unknown and rejected by :meth:`MCPSkillResourceTemplate.materialize`. +_URI_TEMPLATE_VAR_RE = re.compile(r"\{([a-zA-Z0-9_]+)\}") + + +@experimental(feature_id=ExperimentalFeature.MCP_SKILLS) +class MCPSkillResourceTemplate: + """A parameterized skill namespace discovered from an MCP ``skill://index.json`` index. + + Represents an index entry of type ``mcp-resource-template``: instead of a + single concrete skill, the entry's ``url`` is an + `RFC 6570 `_ URI template (e.g. + ``skill://docs/{product}/SKILL.md``) that resolves to a ``SKILL.md`` + resource once its variables are bound. One template therefore stands in for + a *family* of skills — one per binding of its variables. + + Per the SEP-2640 MCP binding, ``mcp-resource-template`` entries omit the + ``name`` field (a single template has no one name) and only the + ``description`` and ``url`` (template) fields are present. + + Concrete skills are **not** materialized during discovery, because the + template variables require values that the index does not provide (they are + supplied by the host, the user, or the server's + ``resources/templates/list`` capability). Call :meth:`materialize` with an + explicit variable binding to obtain a ready-to-use :class:`MCPSkill`. + + Attributes: + description: Human-readable description shared by every skill in the family. + url_template: The raw RFC 6570 URI template string. + + Examples: + .. code-block:: python + + # template.url_template == "skill://docs/{product}/SKILL.md" + skill = template.materialize(name="widget-docs", variables={"product": "widget"}) + content = await skill.get_content() # reads skill://docs/widget/SKILL.md + """ + + def __init__( + self, + *, + description: str, + url_template: str, + client: ClientSession, + ) -> None: + """Initialize an MCPSkillResourceTemplate. + + Args: + description: Human-readable description for the skill family. + url_template: An RFC 6570 URI template resolving to a ``SKILL.md`` + resource once its variables are bound + (e.g. ``skill://docs/{product}/SKILL.md``). + client: The MCP client session used to fetch resources on demand + for materialized skills. + + Raises: + ValueError: If ``url_template`` is empty or whitespace. + """ + if not url_template or not url_template.strip(): + raise ValueError("url_template cannot be empty.") + + self.description = description + self.url_template = url_template + self._client = client + + @property + def variables(self) -> list[str]: + """The template variable names, in first-appearance order, without duplicates. + + For ``skill://docs/{product}/{section}/SKILL.md`` this returns + ``["product", "section"]``. + """ + seen: set[str] = set() + ordered: list[str] = [] + for match in _URI_TEMPLATE_VAR_RE.finditer(self.url_template): + var = match.group(1) + if var not in seen: + seen.add(var) + ordered.append(var) + return ordered + + def expand(self, variables: dict[str, str]) -> str: + """Expand the URI template into a concrete resource URI. + + Performs RFC 6570 Level-1 simple string expansion: every ``{var}`` + expression is replaced with the percent-encoded value supplied for + ``var``. Reserved URI characters in values are percent-encoded so that + a value cannot smuggle in extra path segments or a scheme; the path + separator ``/`` is also encoded, matching RFC 6570 simple expansion. + + Args: + variables: A mapping from each template variable name to its value. + + Returns: + The expanded URI with all variables substituted. + + Raises: + ValueError: If a value is missing for any variable referenced by + the template. + """ + from urllib.parse import quote + + missing = [var for var in self.variables if var not in variables] + if missing: + raise ValueError( + f"Missing value(s) for URI template variable(s): {', '.join(missing)}. " + f"Template '{self.url_template}' requires: {', '.join(self.variables)}." + ) + + def _replace(match: re.Match[str]) -> str: + return quote(str(variables[match.group(1)]), safe="") + + return _URI_TEMPLATE_VAR_RE.sub(_replace, self.url_template) + + def materialize( + self, + *, + name: str, + variables: dict[str, str], + description: str | None = None, + ) -> MCPSkill: + """Materialize a concrete :class:`MCPSkill` by binding the template variables. + + Expands :attr:`url_template` against *variables* to obtain the concrete + ``SKILL.md`` URI, then constructs an :class:`MCPSkill` that fetches its + content and sibling resources from the same MCP server on demand. + + Args: + name: The name to assign to the materialized skill. Required because + ``mcp-resource-template`` entries carry no name of their own; + callers choose a name appropriate to the chosen variable binding + (e.g. ``"widget-docs"`` for ``product=widget``). Must satisfy the + Agent Skills name rules. + variables: A mapping from each template variable name to its value. + description: Optional description override for the materialized + skill. Defaults to the template's :attr:`description`. + + Returns: + A ready-to-use :class:`MCPSkill` pointing at the resolved ``SKILL.md``. + + Raises: + ValueError: If a value is missing for any template variable, or if + *name* / *description* are invalid per the Agent Skills spec. + """ + uri = self.expand(variables) + frontmatter = SkillFrontmatter(name=name, description=description or self.description) + return MCPSkill(frontmatter=frontmatter, skill_md_uri=uri, client=self._client) + + @experimental(feature_id=ExperimentalFeature.MCP_SKILLS) class MCPSkillsSource(SkillsSource): """A :class:`SkillsSource` that discovers Agent Skills served over MCP. @@ -3602,11 +3753,15 @@ class MCPSkillsSource(SkillsSource): the host fetches its body on demand via ``resources/read`` when the skill content is needed. - Only index entries of type ``skill-md`` are supported; entries of any - other type are silently skipped. + :meth:`get_skills` returns concrete skills, which are sourced from + ``skill-md`` entries; entries of any other type are skipped there. + Index entries of type ``mcp-resource-template`` describe *parameterized* + skill namespaces (an RFC 6570 URI template in place of a concrete URL) and + are surfaced separately via :meth:`get_resource_templates`, since they + require variable values to materialize into usable skills. If ``skill://index.json`` is absent, unreadable, empty, or fails to - parse, this source returns an empty list. + parse, both methods return an empty list. Examples: .. code-block:: python @@ -3615,10 +3770,16 @@ class MCPSkillsSource(SkillsSource): source = MCPSkillsSource(client=session) skills = await source.get_skills() + + # Parameterized (mcp-resource-template) skill namespaces: + templates = await source.get_resource_templates() + for template in templates: + skill = template.materialize(name="widget-docs", variables={"product": "widget"}) """ _INDEX_URI: Final[str] = "skill://index.json" _SKILL_MD_TYPE: Final[str] = "skill-md" + _RESOURCE_TEMPLATE_TYPE: Final[str] = "mcp-resource-template" def __init__(self, client: ClientSession) -> None: """Initialize an MCPSkillsSource. @@ -3657,6 +3818,39 @@ async def get_skills(self) -> list[Skill]: logger.info("Successfully loaded %d skills from MCP server", len(skills)) return skills + async def get_resource_templates(self) -> list[MCPSkillResourceTemplate]: + """Discover ``mcp-resource-template`` entries from the MCP server. + + Reads ``skill://index.json``, parses it, and returns one + :class:`MCPSkillResourceTemplate` per ``mcp-resource-template`` entry. + Each template describes a *parameterized* skill namespace: its ``url`` + is an RFC 6570 URI template that resolves to a concrete ``SKILL.md`` once + its variables are bound. + + Templates are returned **separately** from :meth:`get_skills` rather than + as concrete skills, because materializing a skill requires values for the + template variables that the index does not provide. Bind the variables + with :meth:`MCPSkillResourceTemplate.materialize` to obtain a usable + :class:`MCPSkill`. + + Returns: + A list of discovered :class:`MCPSkillResourceTemplate` instances. + Empty when the server advertises no template entries (or no index). + """ + index = await self._try_read_index() + if index is None: + return [] + + templates: list[MCPSkillResourceTemplate] = [] + for entry in index.skills: + template = self._try_create_template(entry) + if template is not None: + templates.append(template) + logger.info("Loaded MCP skill resource template: %s", template.url_template) + + logger.info("Successfully loaded %d skill resource templates from MCP server", len(templates)) + return templates + async def _try_read_index(self) -> _McpSkillIndex | None: """Attempt to read and parse ``skill://index.json`` from the MCP server. @@ -3722,5 +3916,40 @@ def _try_create_skill(self, entry: _McpSkillIndexEntry) -> MCPSkill | None: return MCPSkill(frontmatter=fm, skill_md_uri=entry.url, client=self._client) + def _try_create_template(self, entry: _McpSkillIndexEntry) -> MCPSkillResourceTemplate | None: + """Attempt to create an :class:`MCPSkillResourceTemplate` from an index entry. + + Only entries of type ``mcp-resource-template`` are considered. Such + entries omit the ``name`` field per the SEP-2640 binding, so only + ``description`` and ``url`` (the URI template) are validated here. + + Args: + entry: A single entry from the skill index. + + Returns: + An :class:`MCPSkillResourceTemplate` if the entry is a valid + ``mcp-resource-template``, or ``None`` if it should be skipped. + """ + if entry.type != self._RESOURCE_TEMPLATE_TYPE: + return None + + if not entry.description or not entry.description.strip(): + logger.debug("Skipping resource-template entry: missing required 'description' field") + return None + + if not entry.url or not entry.url.strip(): + logger.debug("Skipping resource-template entry: missing required 'url' field") + return None + + try: + return MCPSkillResourceTemplate( + description=entry.description, + url_template=entry.url, + client=self._client, + ) + except ValueError as ex: + logger.debug("Skipping resource-template entry: invalid template: %s", ex) + return None + # endregion diff --git a/python/packages/core/tests/core/test_mcp_skills.py b/python/packages/core/tests/core/test_mcp_skills.py index 74993997d0e..44776d5ea3a 100644 --- a/python/packages/core/tests/core/test_mcp_skills.py +++ b/python/packages/core/tests/core/test_mcp_skills.py @@ -1,6 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. -"""Tests for MCP-based skills (MCPSkillsSource, MCPSkill, MCPSkillResource).""" +"""Tests for MCP-based skills (MCPSkillsSource, MCPSkill, MCPSkillResource, MCPSkillResourceTemplate).""" from __future__ import annotations @@ -18,7 +18,7 @@ ) from pydantic import AnyUrl -from agent_framework import MCPSkill, MCPSkillResource, MCPSkillsSource +from agent_framework import MCPSkill, MCPSkillResource, MCPSkillResourceTemplate, MCPSkillsSource from agent_framework._skills import _parse_mcp_skill_index # --------------------------------------------------------------------------- @@ -47,6 +47,26 @@ ], }) +# Index that mixes a concrete skill-md entry with an mcp-resource-template entry. +# Per the SEP-2640 binding, template entries omit "name" and carry an RFC 6570 +# URI template in "url". +SAMPLE_TEMPLATE_INDEX = json.dumps({ + "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json", + "skills": [ + { + "name": "unit-converter", + "type": "skill-md", + "description": "Convert between common units.", + "url": "skill://unit-converter/SKILL.md", + }, + { + "type": "mcp-resource-template", + "description": "Per-product documentation skill", + "url": "skill://docs/{product}/SKILL.md", + }, + ], +}) + def _make_text_result(text: str, uri: str = "skill://test") -> ReadResourceResult: """Create a ReadResourceResult with a single TextResourceContents.""" @@ -425,6 +445,9 @@ async def test_unsupported_type_is_skipped(self) -> None: @pytest.mark.asyncio async def test_template_type_is_skipped(self) -> None: + # mcp-resource-template entries are parameterized namespaces; they are not + # returned as concrete skills by get_skills() (they are surfaced separately + # via get_resource_templates()). index_json = json.dumps({ "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json", "skills": [ @@ -635,3 +658,177 @@ async def test_index_timeout_error_propagates(self) -> None: source = MCPSkillsSource(client=client) with pytest.raises(TimeoutError): await source.get_skills() + + +# --------------------------------------------------------------------------- +# MCPSkillResourceTemplate tests +# --------------------------------------------------------------------------- + + +class TestMCPSkillResourceTemplate: + """Tests for MCPSkillResourceTemplate (the mcp-resource-template skill kind).""" + + def test_variables_extracted_in_order_without_duplicates(self) -> None: + client = AsyncMock() + template = MCPSkillResourceTemplate( + description="docs", + url_template="skill://docs/{product}/{section}/{product}/SKILL.md", + client=client, + ) + assert template.variables == ["product", "section"] + + def test_variables_empty_for_static_url(self) -> None: + client = AsyncMock() + template = MCPSkillResourceTemplate(description="docs", url_template="skill://docs/SKILL.md", client=client) + assert template.variables == [] + + def test_expand_substitutes_variables(self) -> None: + client = AsyncMock() + template = MCPSkillResourceTemplate( + description="docs", url_template="skill://docs/{product}/SKILL.md", client=client + ) + assert template.expand({"product": "widget"}) == "skill://docs/widget/SKILL.md" + + def test_expand_percent_encodes_reserved_characters(self) -> None: + # RFC 6570 simple expansion percent-encodes reserved characters (including "/") + # so a value cannot inject extra path segments. + client = AsyncMock() + template = MCPSkillResourceTemplate( + description="docs", url_template="skill://docs/{product}/SKILL.md", client=client + ) + assert template.expand({"product": "a/b c"}) == "skill://docs/a%2Fb%20c/SKILL.md" + + def test_expand_missing_variable_raises(self) -> None: + client = AsyncMock() + template = MCPSkillResourceTemplate( + description="docs", url_template="skill://docs/{product}/{section}/SKILL.md", client=client + ) + with pytest.raises(ValueError, match="Missing value"): + template.expand({"product": "widget"}) + + def test_empty_url_template_raises(self) -> None: + client = AsyncMock() + with pytest.raises(ValueError, match="url_template cannot be empty"): + MCPSkillResourceTemplate(description="docs", url_template=" ", client=client) + + @pytest.mark.asyncio + async def test_materialize_produces_working_skill(self) -> None: + client = _make_client(**{"skill://docs/widget/SKILL.md": _make_text_result(SAMPLE_SKILL_MD)}) + template = MCPSkillResourceTemplate( + description="Per-product documentation skill", + url_template="skill://docs/{product}/SKILL.md", + client=client, + ) + + skill = template.materialize(name="widget-docs", variables={"product": "widget"}) + assert isinstance(skill, MCPSkill) + assert skill.frontmatter.name == "widget-docs" + # Description defaults to the template's description. + assert skill.frontmatter.description == "Per-product documentation skill" + + content = await skill.get_content() + assert "Body content here." in content + client.read_resource.assert_awaited_once() + + @pytest.mark.asyncio + async def test_materialize_description_override(self) -> None: + client = _make_client() + template = MCPSkillResourceTemplate( + description="default", url_template="skill://docs/{product}/SKILL.md", client=client + ) + skill = template.materialize(name="widget-docs", variables={"product": "widget"}, description="custom desc") + assert skill.frontmatter.description == "custom desc" + + def test_materialize_invalid_name_raises(self) -> None: + client = AsyncMock() + template = MCPSkillResourceTemplate( + description="docs", url_template="skill://docs/{product}/SKILL.md", client=client + ) + with pytest.raises(ValueError): + template.materialize(name="Invalid Name", variables={"product": "widget"}) + + def test_materialize_missing_variable_raises(self) -> None: + client = AsyncMock() + template = MCPSkillResourceTemplate( + description="docs", url_template="skill://docs/{product}/SKILL.md", client=client + ) + with pytest.raises(ValueError, match="Missing value"): + template.materialize(name="widget-docs", variables={}) + + +class TestMCPSkillsSourceResourceTemplates: + """Tests for MCPSkillsSource.get_resource_templates discovery.""" + + @pytest.mark.asyncio + async def test_discovers_template_entries(self) -> None: + client = _make_client(**{ + "skill://index.json": _make_text_result(SAMPLE_TEMPLATE_INDEX, uri="skill://index.json"), + }) + source = MCPSkillsSource(client=client) + templates = await source.get_resource_templates() + + assert len(templates) == 1 + assert templates[0].url_template == "skill://docs/{product}/SKILL.md" + assert templates[0].description == "Per-product documentation skill" + assert templates[0].variables == ["product"] + + @pytest.mark.asyncio + async def test_get_skills_and_templates_are_complementary(self) -> None: + # The same index yields one concrete skill (skill-md) and one template; + # each method returns only its own kind. + client = _make_client(**{ + "skill://index.json": _make_text_result(SAMPLE_TEMPLATE_INDEX, uri="skill://index.json"), + }) + source = MCPSkillsSource(client=client) + + skills = await source.get_skills() + templates = await source.get_resource_templates() + + assert [s.frontmatter.name for s in skills] == ["unit-converter"] + assert len(templates) == 1 + + @pytest.mark.asyncio + async def test_no_index_returns_empty_templates(self) -> None: + client = _make_client() + source = MCPSkillsSource(client=client) + assert await source.get_resource_templates() == [] + + @pytest.mark.asyncio + async def test_template_missing_url_is_skipped(self) -> None: + index_json = json.dumps({ + "skills": [{"type": "mcp-resource-template", "description": "no url"}], + }) + client = _make_client(**{"skill://index.json": _make_text_result(index_json, uri="skill://index.json")}) + source = MCPSkillsSource(client=client) + assert await source.get_resource_templates() == [] + + @pytest.mark.asyncio + async def test_template_missing_description_is_skipped(self) -> None: + index_json = json.dumps({ + "skills": [{"type": "mcp-resource-template", "url": "skill://docs/{product}/SKILL.md"}], + }) + client = _make_client(**{"skill://index.json": _make_text_result(index_json, uri="skill://index.json")}) + source = MCPSkillsSource(client=client) + assert await source.get_resource_templates() == [] + + @pytest.mark.asyncio + async def test_skill_md_entries_are_not_returned_as_templates(self) -> None: + client = _make_client(**{ + "skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"), + }) + source = MCPSkillsSource(client=client) + assert await source.get_resource_templates() == [] + + @pytest.mark.asyncio + async def test_materialized_skill_resolves_via_template_source(self) -> None: + # End-to-end: discover the template, materialize it, and fetch the + # resolved SKILL.md content from the same MCP server. + client = _make_client(**{ + "skill://index.json": _make_text_result(SAMPLE_TEMPLATE_INDEX, uri="skill://index.json"), + "skill://docs/widget/SKILL.md": _make_text_result(SAMPLE_SKILL_MD), + }) + source = MCPSkillsSource(client=client) + template = (await source.get_resource_templates())[0] + skill = template.materialize(name="widget-docs", variables={"product": "widget"}) + content = await skill.get_content() + assert "Body content here." in content