Skip to content
Merged
2 changes: 2 additions & 0 deletions python/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **agent-framework-azurefunctions**, **agent-framework-core**, **agent-framework-durabletask**: Add HITL response-URL addressing for requests raised from inside workflows ([#7001](https://github.com/microsoft/agent-framework/pull/7001))
- **agent-framework-core**: Add cross-session origin attribution to context-injected messages ([#7041](https://github.com/microsoft/agent-framework/pull/7041))
- **agent-framework-core**, **agent-framework-tools**: Warn when auto-approved tools have name collisions ([#7090](https://github.com/microsoft/agent-framework/pull/7090))
- **agent-framework-core**: Add a `session_provider` option to `MCPSkillsSource` and `MCPSkill` (mutually exclusive with `client`) that resolves the MCP session on every fetch, keeping cached skills reconnect-safe when the underlying session is replaced ([#7135](https://github.com/microsoft/agent-framework/pull/7135))
- **agent-framework-hosting-a2a**: Add app-owned A2A hosting helpers ([#7050](https://github.com/microsoft/agent-framework/pull/7050))
- **agent-framework-hosting-mcp**: Add app-owned MCP hosting helpers for exposing agents and workflows as native MCP tools ([#7209](https://github.com/microsoft/agent-framework/pull/7209))
- **agent-framework-hosting-responses**: [BREAKING] Add Responses conversation ID creation and parsing helpers, and distinguish conversation IDs from previous response IDs ([#7234](https://github.com/microsoft/agent-framework/pull/7234))
Expand Down Expand Up @@ -64,6 +65,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **agent-framework-core**: Forward `header_provider` headers to streamable HTTP MCP transports ([#7218](https://github.com/microsoft/agent-framework/pull/7218))
- **agent-framework-core**: Prevent compaction from emitting empty projections ([#7219](https://github.com/microsoft/agent-framework/pull/7219))
- **agent-framework-core**: Return MCP tool-use sampling results to the requesting server ([#7189](https://github.com/microsoft/agent-framework/pull/7189))
- **agent-framework-foundry-hosting**: Make `FoundryToolbox.as_skills_provider()` cache toolbox skill discovery by default so `skill://index.json` is read once instead of on every agent run, give `disable_caching` an observable effect, and add a `cache_refresh_interval` option ([#7135](https://github.com/microsoft/agent-framework/pull/7135))
- **agent-framework-hosting**, **agent-framework-hosting-responses**: Isolate stored session snapshots from later mutations ([#7141](https://github.com/microsoft/agent-framework/pull/7141))
- **agent-framework-ollama**: Generate distinct call ids for parallel tool calls ([#6822](https://github.com/microsoft/agent-framework/pull/6822))
- **agent-framework-orchestrations**: Prevent the Magentic manager from duplicating conversation history ([#6297](https://github.com/microsoft/agent-framework/pull/6297))
Expand Down
2 changes: 1 addition & 1 deletion python/packages/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ agent_framework/
- **`SkillScriptRunner`** - Protocol for file-based script execution. Any callable matching `(skill, script, args) -> Any` satisfies it. Code-defined scripts do not use a runner.
- **`SkillScriptArgumentParser`** - Public type alias for an optional callable `(raw args: dict | list[str] | str | None) -> dict | None` that converts the raw `args` value before an `InlineSkillScript` runs (applied before the inline list-args guard). It is an opt-in customization hook (port of .NET PR #6498) that lets callers support backends sending tool-call arguments in a non-conforming shape (e.g. vLLM JSON strings). The output is constrained to a `dict` (named keyword arguments) or `None`, because inline scripts bind arguments by keyword name. Supply it via the `argument_parser=` constructor arg on `InlineSkillScript`, `InlineSkill` (default for scripts added via `@skill.script`), or `ClassSkill` (default for scripts discovered via `@ClassSkill.script`). When `None` (the default), the raw value is used unchanged. File-based scripts are unaffected (their runner owns arg handling).
- **`SkillsProvider`** - Context provider (extends `ContextProvider`) that discovers file-based skills from `SKILL.md` files and/or accepts code-defined `Skill` instances. Follows progressive disclosure: advertise → load → read resources / run scripts. By default all three tools it exposes (`load_skill`, `read_skill_resource`, `run_skill_script`) are registered with `approval_mode="always_require"`, so every skill operation needs approval. To run unattended, pass one of the static auto-approval rules to `ToolApprovalMiddleware` (via `auto_approval_rules`): `SkillsProvider.read_only_tools_auto_approval_rule` approves only the read-only tools (`load_skill`, `read_skill_resource`) while still prompting for `run_skill_script`, and `SkillsProvider.all_tools_auto_approval_rule` approves every skill tool including script execution. Both rules reject any call carrying a `server_label` so they stay scoped to this provider's local tools and never auto-approve a same-named hosted tool. Alternatively, for trusted skills, the constructor / `from_paths` kwargs `disable_load_skill_approval`, `disable_read_skill_resource_approval`, and `disable_run_skill_script_approval` (all default `False`) opt individual tools out of approval entirely by registering them with `approval_mode="never_require"` (the auto-approval rules only apply to tools that still require approval). The tool names are also exposed as class constants (`LOAD_SKILL_TOOL_NAME`, `READ_SKILL_RESOURCE_TOOL_NAME`, `RUN_SKILL_SCRIPT_TOOL_NAME`).
- **`SkillsSource` decorators** - Skill sources are composable: `SkillsSource` is the abstract base, with concrete sources (`InMemorySkillsSource`, `FileSkillsSource`, `MCPSkillsSource`) and decorators that wrap an inner source — `AggregatingSkillsSource` (concatenate several sources), `FilteringSkillsSource` (predicate filter), `DeduplicatingSkillsSource` (first-wins by name), and `CachingSkillsSource` (cache the inner source's skills list). `DelegatingSkillsSource` is the abstract base for decorators. **`get_skills` takes a `SkillsSourceContext`**: every source/decorator implements `async def get_skills(self, context: SkillsSourceContext) -> list[Skill]` and forwards `context` to inner sources. `SkillsSourceContext` (frozen) carries the invoking `agent` (`SupportsAgentRun`) and optional `session` (`AgentSession | None`); `SkillsProvider` builds it from `before_run`'s `agent`/`session` and passes it into the pipeline. `FilteringSkillsSource`'s predicate is context-aware: `Callable[[Skill, SkillsSourceContext], bool]` (port of .NET #6797). **Default caching is applied only to the built-in, context-independent leaf sources**: for the `Skill` / sequence-of-skills / `from_paths` constructors, `SkillsProvider` builds `DeduplicatingSkillsSource(CachingSkillsSource(<file|in-memory leaf>))` so expensive filesystem/network discovery runs once. A **caller-supplied `SkillsSource` is used as-is — never auto-wrapped in caching or deduplication** — because auto-caching a context-aware caller source in a single shared bucket would replay the first invocation's skills for later `SkillsSourceContext`s and leak skills across agents/tenants (matches .NET, whose custom-source constructor also adds no caching/dedup). Callers who want caching on a custom pipeline compose `CachingSkillsSource(inner, cache_isolation_key_selector=...)` themselves. `disable_caching=True` only affects the built-in leaf caching (it has no effect on a caller-supplied source, which is never cached). `CachingSkillsSource` shares a single in-flight fetch across concurrent callers (per cache key) and does not update its cache on a failed fetch, so the next call retries (an initial failure leaves the cache empty; a refresh failure keeps the previously cached list). By default all callers share one cache bucket; pass `cache_isolation_key_selector=Callable[[SkillsSourceContext], str | None]` to cache separately per key (e.g. per agent name) for context-aware inner sources — the key should be low-cardinality and stable, and returning `None` (or leaving the selector `None`) uses the shared bucket. By default a cached list never expires; pass `refresh_interval=timedelta(...)` (port of .NET `CachingAgentSkillsSourceOptions.RefreshInterval`) to treat a cached list as stale once it is older than the interval so the next call re-queries the inner source (useful when an inner source such as `MCPSkillsSource` changes over the process lifetime; a zero/negative interval makes every result immediately stale, and a failed refresh keeps the prior list and retries). Freshness is measured with a monotonic clock (`time.monotonic()`). `SkillsProvider.__init__` / `from_paths` expose a `cache_refresh_interval` kwarg that is threaded into the built-in `CachingSkillsSource` (it has no effect on a caller-supplied source or when `disable_caching=True`).
- **`SkillsSource` decorators** - Skill sources are composable: `SkillsSource` is the abstract base, with concrete sources (`InMemorySkillsSource`, `FileSkillsSource`, `MCPSkillsSource`) and decorators that wrap an inner source — `AggregatingSkillsSource` (concatenate several sources), `FilteringSkillsSource` (predicate filter), `DeduplicatingSkillsSource` (first-wins by name), and `CachingSkillsSource` (cache the inner source's skills list). `DelegatingSkillsSource` is the abstract base for decorators. **`get_skills` takes a `SkillsSourceContext`**: every source/decorator implements `async def get_skills(self, context: SkillsSourceContext) -> list[Skill]` and forwards `context` to inner sources. `SkillsSourceContext` (frozen) carries the invoking `agent` (`SupportsAgentRun`) and optional `session` (`AgentSession | None`); `SkillsProvider` builds it from `before_run`'s `agent`/`session` and passes it into the pipeline. `FilteringSkillsSource`'s predicate is context-aware: `Callable[[Skill, SkillsSourceContext], bool]` (port of .NET #6797). **Default caching is applied only to the built-in, context-independent leaf sources**: for the `Skill` / sequence-of-skills / `from_paths` constructors, `SkillsProvider` builds `DeduplicatingSkillsSource(CachingSkillsSource(<file|in-memory leaf>))` so expensive filesystem/network discovery runs once. A **caller-supplied `SkillsSource` is used as-is — never auto-wrapped in caching or deduplication** — because auto-caching a context-aware caller source in a single shared bucket would replay the first invocation's skills for later `SkillsSourceContext`s and leak skills across agents/tenants (matches .NET, whose custom-source constructor also adds no caching/dedup). Callers who want caching on a custom pipeline compose `CachingSkillsSource(inner, cache_isolation_key_selector=...)` themselves. `disable_caching=True` only affects the built-in leaf caching (it has no effect on a caller-supplied source, which is never cached). `CachingSkillsSource` shares a single in-flight fetch across concurrent callers (per cache key) and does not update its cache on a failed fetch, so the next call retries (an initial failure leaves the cache empty; a refresh failure keeps the previously cached list). By default all callers share one cache bucket; pass `cache_isolation_key_selector=Callable[[SkillsSourceContext], str | None]` to cache separately per key (e.g. per agent name) for context-aware inner sources — the key should be low-cardinality and stable, and returning `None` (or leaving the selector `None`) uses the shared bucket. By default a cached list never expires; pass `refresh_interval=timedelta(...)` (port of .NET `CachingAgentSkillsSourceOptions.RefreshInterval`) to treat a cached list as stale once it is older than the interval so the next call re-queries the inner source (useful when an inner source such as `MCPSkillsSource` changes over the process lifetime; a zero/negative interval makes every result immediately stale, and a failed refresh keeps the prior list and retries). Freshness is measured with a monotonic clock (`time.monotonic()`). `SkillsProvider.__init__` / `from_paths` expose a `cache_refresh_interval` kwarg that is threaded into the built-in `CachingSkillsSource` (it has no effect on a caller-supplied source or when `disable_caching=True`). **`MCPSkillsSource` and `MCPSkill` accept exactly one of `client` (a fixed `ClientSession`) or `session_provider` (`Callable[[], ClientSession]`, resolved on every fetch); providing both/neither raises `ValueError`.** Use `session_provider` when the underlying session may be swapped over time — e.g. a reconnecting `MCPTool`/`FoundryToolbox` whose `session` is replaced on reconnect — so cached `MCPSkill`s keep fetching against the live session instead of a closed one (`MCPSkillsSource` forwards its provider to every `MCPSkill` it creates). A fixed `client` is safe only when the session outlives the skills.

### Model Context Protocol (`_mcp.py`)

Expand Down
96 changes: 85 additions & 11 deletions python/packages/core/agent_framework/_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -4054,6 +4054,40 @@ def _parse_mcp_skill_index(text: str) -> _McpSkillIndex:
return _McpSkillIndex(schema=raw.get("$schema"), skills=entries)


def _resolve_mcp_session_provider(
client: ClientSession | None,
session_provider: Callable[[], ClientSession] | None,
) -> Callable[[], ClientSession]:
"""Normalize the two MCP session inputs into a single session resolver.

Callers supply **exactly one** of a fixed ``client`` or a
``session_provider`` callable. A fixed client is wrapped in a provider that
always returns it; a provider is used as-is so the session is resolved on
every call (reconnect-safe for sources whose underlying session is replaced
over time, e.g. a reconnecting :class:`~agent_framework.MCPTool`).

Args:
client: A fixed MCP client session, or ``None``.
session_provider: A callable returning the current MCP client session,
or ``None``.

Returns:
A callable that returns the MCP client session to use.

Raises:
ValueError: If both or neither of *client* and *session_provider* are
provided.
"""
if client is not None and session_provider is not None:
raise ValueError("Provide exactly one of 'client' or 'session_provider', not both.")
if session_provider is not None:
return session_provider
if client is None:
raise ValueError("Provide exactly one of 'client' or 'session_provider'.")
fixed: ClientSession = client
return lambda: fixed


@experimental(feature_id=ExperimentalFeature.MCP_SKILLS)
class MCPSkillResource(SkillResource):
"""A :class:`SkillResource` backed by content fetched from an MCP server.
Expand Down Expand Up @@ -4116,21 +4150,39 @@ def __init__(
self,
frontmatter: SkillFrontmatter,
skill_md_uri: str,
client: ClientSession,
client: ClientSession | None = None,
*,
session_provider: Callable[[], ClientSession] | None = None,
) -> None:
"""Initialize an MCPSkill.

Provide **exactly one** of *client* or *session_provider*.

Args:
frontmatter: The parsed frontmatter metadata for this skill.
skill_md_uri: The full MCP resource URI of the ``SKILL.md`` resource
(e.g. ``skill://unit-converter/SKILL.md``). The skill's root URI
is derived by stripping the trailing ``SKILL.md`` segment.
client: The MCP client session used to fetch resources on demand.
client: A fixed MCP client session used to fetch resources on demand.
Use this when the session outlives the skill (e.g. a caller-owned
long-lived session).

Keyword Args:
session_provider: A callable returning the current MCP client session,
resolved on every fetch. Prefer this over *client* when the
underlying session may be replaced over the skill's lifetime (for
example, a reconnecting :class:`~agent_framework.MCPTool` whose
``session`` is swapped on reconnect), so a cached skill keeps
using the live session instead of a closed one.

Raises:
ValueError: If both or neither of *client* and *session_provider* are
provided.
"""
self._frontmatter = frontmatter
self._skill_md_uri = skill_md_uri
self._skill_root_uri = self._compute_skill_root_uri(skill_md_uri)
self._client = client
self._session_provider = _resolve_mcp_session_provider(client, session_provider)
self._content: str | None = None

@property
Expand All @@ -4154,7 +4206,7 @@ async def get_content(self) -> str:
if self._content is not None:
return self._content

result = await self._client.read_resource(_mcp_any_url(self._skill_md_uri))
result = await self._session_provider().read_resource(_mcp_any_url(self._skill_md_uri))
text = _mcp_join_text(result)
if not text:
raise ValueError(f"The MCP server returned no text content for SKILL.md resource '{self._skill_md_uri}'.")
Expand Down Expand Up @@ -4184,7 +4236,7 @@ async def get_resource(self, name: str) -> SkillResource | None:

uri = self._skill_root_uri + normalized
try:
result = await self._client.read_resource(_mcp_any_url(uri))
result = await self._session_provider().read_resource(_mcp_any_url(uri))
except Exception as ex:
if _is_mcp_resource_not_found(ex):
logger.debug("MCP resource '%s' not available: %s", uri, ex)
Expand Down Expand Up @@ -4276,14 +4328,36 @@ class MCPSkillsSource(SkillsSource):
_INDEX_URI: Final[str] = "skill://index.json"
_SKILL_MD_TYPE: Final[str] = "skill-md"

def __init__(self, client: ClientSession) -> None:
def __init__(
self,
client: ClientSession | None = None,
*,
session_provider: Callable[[], ClientSession] | None = None,
) -> None:
"""Initialize an MCPSkillsSource.

Provide **exactly one** of *client* or *session_provider*.

Args:
client: An MCP client session connected to a server that
exposes Agent Skills resources.
client: A fixed MCP client session connected to a server that exposes
Agent Skills resources. Use this when the session outlives the
source (e.g. a caller-owned long-lived session).

Keyword Args:
session_provider: A callable returning the current MCP client session,
resolved on every discovery and on each skill's on-demand fetch.
Prefer this over *client* when the underlying session may be
replaced over time (for example, a reconnecting
:class:`~agent_framework.MCPTool` whose ``session`` is swapped on
reconnect), so cached skills keep using the live session. The
provider is forwarded to every :class:`MCPSkill` this source
creates.

Raises:
ValueError: If both or neither of *client* and *session_provider* are
provided.
"""
self._client = client
self._session_provider = _resolve_mcp_session_provider(client, session_provider)

async def get_skills(self, context: SkillsSourceContext) -> list[Skill]:
"""Discover and return skills from the MCP server.
Expand Down Expand Up @@ -4327,7 +4401,7 @@ async def _try_read_index(self) -> _McpSkillIndex | None:
absent, empty, or malformed.
"""
try:
result = await self._client.read_resource(_mcp_any_url(self._INDEX_URI))
result = await self._session_provider().read_resource(_mcp_any_url(self._INDEX_URI))
except Exception as ex:
if _is_mcp_resource_not_found(ex):
logger.debug("No skill://index.json resource available on MCP server: %s", ex)
Expand Down Expand Up @@ -4382,7 +4456,7 @@ def _try_create_skill(self, entry: _McpSkillIndexEntry) -> MCPSkill | None:
logger.debug("Skipping entry '%s': invalid metadata: %s", entry.name, ex)
return None

return MCPSkill(frontmatter=fm, skill_md_uri=entry.url, client=self._client)
return MCPSkill(frontmatter=fm, skill_md_uri=entry.url, session_provider=self._session_provider)


# endregion
Loading
Loading