diff --git a/docs/decisions/0045-python-mcp-v2-client-lifecycle.md b/docs/decisions/0045-python-mcp-v2-client-lifecycle.md new file mode 100644 index 00000000000..765daa5d134 --- /dev/null +++ b/docs/decisions/0045-python-mcp-v2-client-lifecycle.md @@ -0,0 +1,248 @@ +--- +status: approved +date: 2026-10-15 +deciders: jpalvarezl, eavanvalkenburg +--- + +# Use the MCP SDK Client as the primary Python MCP client + +## Context and Problem Statement + +The Python `MCPTool` implementation originally owned an MCP transport, constructed a low-level `ClientSession`, and +used that session for every operation. The MCP Python SDK 2.x adds a high-level `Client` that owns protocol +negotiation and standard client behavior across modern and legacy peers. + +Using `Client` only for negotiation and then routing operations through `client.session` would bypass high-level +behavior such as multi-round-trip request (MRTR) handling and response caching. At the same time, existing callers +can supply an already-created `ClientSession` and expect Agent Framework not to take ownership of it, while callers +using the modern API need to supply an already-entered high-level `Client` without transferring its ownership. + +This decision defines the lasting boundary between the two SDK levels and three ownership modes, including lifecycle, +caching, subscription recovery, and the meaning of `MCPTool.session`. + +This ADR covers the outbound Python MCP client. Hosting/server code continues to use the SDK server APIs. + +## Decision Drivers + +- One Agent Framework API must support modern and legacy MCP peers. +- Standard protocol behavior should remain owned by the MCP SDK rather than be duplicated in Agent Framework. +- Framework-created resources must be closed by the task that opened them, including cancellation and failed + connection attempts. +- Caller-supplied Clients, sessions, and HTTP clients must remain caller-owned. +- Reconnect, authenticated header identity, catalog publication, security, tracing, and result conversion remain + Agent Framework policy. +- Tools, prompts, and resources should follow one operation-routing rule. +- Tasks and durable human-input continuation are separate from base MRTR support. + +## Considered Options + +- Retain the high-level SDK `Client` and use it for standard operations in both ownership modes, with `ClientSession` + as a deprecated caller-owned low-level compatibility path. +- Use `Client` only for negotiation while continuing operations through `client.session`. +- Reimplement negotiation, MRTR, caching, and subscriptions around `ClientSession`. +- Add separate modern and legacy Agent Framework tool classes or a public protocol-mode switch. + +## Decision Outcome + +Chosen option: use the high-level SDK `Client` for both framework-owned and caller-owned high-level connections. +The caller-owned `ClientSession` path is retained for backward compatibility but is considered deprecated for new +usage; callers should prefer supplying a high-level `Client`. + +The private `_MCPConnection` protocol is the operation seam. Its implementations deliberately do not expose both +SDK levels: + +- `_ClientMCPConnection` uses an entered high-level `mcp.Client`. Its `is_framework_owned` flag records whether + Agent Framework created and manages the Client or the caller supplied and manages it. Its `client` property + references that Client and its `session` property remains `None`. Tool, prompt, resource, catalog, logging, and + liveness operations route through `Client`; they never use `client.session` as the operation provider. +- `_SessionMCPConnection` wraps a caller-supplied `ClientSession`. This is a deprecated low-level compatibility + path retained for existing applications. Its `session` property references that session and its `client` property + remains `None`. Operations call the session directly and do not gain high-level caching or automatic MRTR behavior. + +`MCPTool.session` is therefore meaningful only for the deprecated caller-supplied low-level path. High-level Client +connections do not publish the SDK Client's internal compatibility session through that property. + +### Ownership model + +Agent Framework owns: + +- transport and Client construction, Client entry, and resource cleanup for framework-created connections; +- the lifecycle-owner task, locks, connection publication, reset coordination, and cancellation cleanup; +- request-scoped HTTP header identity and reconnects caused by identity changes; +- tool argument filtering and trusted request metadata merging; +- result parsing, Agent Framework content conversion, tracing, and error translation; +- atomic catalog publication and rollback after a failed complete fetch; +- the background catalog-subscription task for any high-level Client, including refresh scheduling, retry/backoff, + cancellation, and recovery after a subscription ends or is lost; +- security and approval policy. + +For **caller-supplied** connections, the caller owns: + +- entering and closing a **supplied** high-level `Client`; +- constructing and closing a **supplied** low-level `ClientSession`; +- the transport, authentication, callbacks, and other configuration already captured by either supplied object. + +The SDK `Client` owns: + +- modern negotiation and legacy initialization fallback; +- standard tool, prompt, and resource operations; +- MRTR callback dispatch, retries, round limits, request state, and input responses; +- response-cache storage and protocol-driven invalidation; +- `Client.listen()` acknowledgment and the subscription event stream; +- protocol, client, and capability metadata. + +### Operation ownership + +| Operation | Framework-owned `Client` | Caller-owned `Client` | Caller-owned `ClientSession` | +|---|---|---|---| +| Connection lifecycle | Agent Framework constructs, enters, and closes the Client | Caller constructs, enters, and closes the Client | Caller constructs and closes the session | +| Connection negotiation | Enter `Client` in automatic mode | Reuse the already-entered Client | Initialize only when required for legacy compatibility | +| `tools/list`, `tools/call` | High-level `Client` operation | High-level `Client` operation | Direct `ClientSession` operation | +| `prompts/list`, `prompts/get` | High-level `Client` operation | High-level `Client` operation | Direct `ClientSession` operation | +| Resource reads | `Client` with SDK cache behavior | `Client` with SDK cache behavior | Direct, uncached session call | +| Catalog subscriptions | Agent Framework owns recovery around `Client.listen()` | Agent Framework owns recovery around `Client.listen()` | Legacy notification handling only | +| `MCPTool.session` | `None` | `None` | The caller-supplied `ClientSession` | +| Tasks or arbitrary extension RPCs | Future supported Client API | Future supported Client API | Raw session request only at an explicit extension boundary | + +### Cache policy + +High-level Clients use SDK response caching for catalog and resource operations regardless of whether Agent Framework +or the caller owns the Client. Modern server `ttlMs` and `cacheScope` hints control reuse and invalidation. Legacy +peers do not provide those hints and remain effectively uncached under the default zero TTL. + +Caller-supplied `ClientSession` connections always call the server directly. Agent Framework does not expose a +shared cache store or cache-mode configuration. Reconnect or an effective header-identity change replaces the +framework-owned Client and its per-client cache. + +### MRTR + +For both framework-owned and caller-owned high-level connections, `tools/call`, `prompts/get`, and `resources/read` +use the Client. The SDK drives embedded elicitation, sampling, and roots requests and retries the original operation +with the required responses and state. + +Agent Framework does not import the SDK's private MRTR driver or implement parallel MRTR loops. A caller-supplied +session remains a raw low-level path and does not gain automatic MRTR merely because callbacks are configured on it. + +Durable pause/resume across processes would require a separate continuation design and function-calling-loop review; +it is not part of this decision. + +### Subscription recovery + +The SDK Client owns protocol-level `listen` acknowledgment and event delivery. Agent Framework owns the long-running +subscription lifecycle around it: + +- one background subscription task per active high-level Client; +- tool and prompt catalog refresh when events arrive; +- refresh before reopening after stream loss or completion; +- retry with bounded backoff for recoverable subscription failures; +- cancellation when the connection is reset, replaced, or closed; +- fallback to legacy catalog notifications when the peer does not support `listen`. + +The recovery task always verifies that the Client is still the active connection before refreshing or reopening, so +an obsolete subscription cannot mutate a replacement connection's catalogs. + +### Caller-supplied high-level Clients + +A caller-supplied `Client` is the preferred way to provide an existing connection: + +- The Client must already be entered before the MCP tool connects. +- Agent Framework does not enter, close, replace, or recreate it. +- Standard operations continue through the high-level Client and retain SDK MRTR and response-cache behavior. +- Agent Framework manages its own catalog-subscription task around `Client.listen()` while the wrapper is connected. +- `MCPTool.session` remains `None`. +- Closing or resetting the wrapper preserves the supplied Client so the caller can continue using or closing it. + +### Caller-supplied low-level sessions + +A caller-supplied `ClientSession` remains a deprecated low-level compatibility path: + +- Agent Framework does not enter, close, replace, wrap, or reconnect it. +- An already negotiated session is reused. +- An unnegotiated session retains legacy initialization compatibility. +- Standard calls remain direct and do not gain high-level MRTR or SDK response caching. +- `MCPTool.session` returns this session and can replace or clear this low-level connection path. + +The SDK does not provide a public API that adopts an existing `ClientSession` into a high-level Client without +opening a separate connection, so Agent Framework does not simulate adoption. + +### Legacy callbacks, logging, and liveness + +Framework-created connections use automatic protocol negotiation even when sampling is configured. The SDK routes +the same callback through the legacy server-to-client back-channel and through modern embedded MRTR requests. + +Modern request metadata carries log-level preference. Legacy `logging/setLevel` and ping behavior remain explicitly +protocol-gated compatibility operations; they are not universal preflights. + +### Tasks + +The supported MCP Python SDK 2.x runtime does not expose the current `io.modelcontextprotocol/tasks` extension. +Tools that may require Tasks are not presented as ordinary supported calls, and the removed long-running Tasks +sample remains deferred. An MRTR `InputRequiredResult` is not a Tasks `input_required` status. + +## Consequences + +- Good, because modern and legacy peers use one public Agent Framework API. +- Good, because standard operations, MRTR, and caching stay on the supported high-level SDK surface. +- Good, because the connection seam distinguishes framework-owned and caller-owned high-level Clients from the + deprecated low-level session path. +- Good, because reconnect, cancellation, header identity, subscription recovery, parsing, and policy remain Agent + Framework concerns. +- Neutral, because caller-supplied low-level sessions intentionally have fewer protocol conveniences. +- Neutral, because `MCPTool.session` returns `None` for all high-level Client connections. +- Bad, because integrations that previously treated `session` as a universal connection handle must instead use the + operation seam, a caller-owned Client, or the deprecated caller-owned session path. + +## Validation + +Committed contract tests cover: + +- modern negotiation and legacy fallback; +- framework-owned Client publication, failure cleanup, reset, reconnect, cancellation, and header identity changes; +- caller-owned Client publication, high-level operation routing, capability reuse, reset preservation, and cleanup; +- standard operations routing through the active Client; +- tool, prompt, and resource MRTR; +- cache TTL, pagination, invalidation, reconnect, and identity changes; +- caller-owned session behavior remaining direct, uncached, and caller-owned; +- subscription refresh, stream loss, retry/backoff, replacement, cancellation, and legacy notification fallback; +- legacy sampling, logging, and ping compatibility. + +## Pros and Cons of the Options + +### Use Client for high-level operations + +- Good, because it uses the SDK's supported high-level surface. +- Good, because it avoids duplicate negotiation, MRTR, and caching code. +- Good, because the low-level session remains available as a deprecated caller-owned compatibility path. +- Bad, because code cannot assume every MCP connection exposes a `ClientSession`. + +### Use Client only for negotiation + +- Good, because it would minimize immediate routing changes. +- Bad, because calling `client.session` for operations bypasses SDK-owned MRTR and caching. +- Bad, because each new protocol feature would reopen the same ownership question. + +### Reimplement behavior around ClientSession + +- Good, because Agent Framework could expose every intermediate protocol round. +- Bad, because it would duplicate SDK protocol policy, cancellation behavior, round limits, and cache invalidation. +- Bad, because it would add continuation complexity without a durable continuation requirement. + +### Add era-specific public classes + +- Good, because callers could force a known protocol era. +- Bad, because callers should not need to know the peer's era before connecting. +- Bad, because it would fragment public classes, samples, and tests. + +## More Information + +- [Python MCP 2026-07-28 umbrella issue](https://github.com/microsoft/agent-framework/issues/8245) +- [MCP Python SDK client guide](https://py.sdk.modelcontextprotocol.io/client/) +- [MCP Python SDK MRTR guide](https://py.sdk.modelcontextprotocol.io/handlers/multi-round-trip/) +- [MCP Python SDK caching guide](https://py.sdk.modelcontextprotocol.io/client/caching/) +- [MCP Python SDK subscriptions guide](https://py.sdk.modelcontextprotocol.io/client/subscriptions/) +- [MCP MRTR specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr) +- [MCP subscriptions specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions) +- [MCP caching specification](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching) +- [ADR 0043: MCP runtime context](0043-python-mcp-runtime-context.md) + +This ADR remains `proposed` until accepted through the repository ADR review process. diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index e1a9a90db2f..ad8a87b1080 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -203,17 +203,21 @@ The vector store API is experimental under the shared `VECTOR_STORES` feature ID - **`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`). - **`FileSkillsSource`** - `SkillsSource` that discovers file-based skills by scanning configured root paths for `SKILL.md`. The **configured root paths define the trust boundary** and are used as given (a root may itself be a symlink); everything discovered *below* a root is link-checked and fails closed. `_discover_skill_directories` rejects any entry that is a symbolic link, junction, or other reparse point (via the shared `agent_framework._filesystem.is_link_or_reparse_point` helper) before descending into it, and rejects a directory whose `SKILL.md` is itself such a link — otherwise a link planted under a root would be adopted as the skill root, and since every later guard treats the skill root as the boundary and only inspects segments below it, the link itself would never be inspected. Resource and script discovery apply the same rule per path segment via `_has_link_or_reparse_point_in_path`; source-discovered resources and scripts retain a `_SkillPathScope` pairing their configured root with their skill directory, and repeat containment, regular-file, and link checks immediately before reading or execution — scanning every segment from the configured root down to the file, so a skill directory (or any directory between it and the root) swapped for a link after discovery is rejected too. An `OSError` while inspecting an entry is treated as unsafe (skip / reject), never as "safe". - **`MCPSkillsSource`** - `SkillsSource` that discovers Agent Skills served over MCP by reading the well-known `skill://index.json` (SEP-2640). Index entries are dispatched by their `type` (case-insensitive): `skill-md` entries become one `MCPSkill` each (its `SKILL.md` body and sibling resources are fetched on demand via `resources/read`), and `archive` entries are downloaded as a single ZIP blob and unpacked **entirely in memory** (via the private `_ArchiveEntryLoader`) into a `FileSkill` whose `SKILL.md` body drives it and whose sibling files (matching the resource extensions, within the search depth) become in-memory `InlineSkillResource` resources. **Nothing is written to disk** — there are no temporary directories to create, own, or prune (this is a deliberate divergence from .NET, which extracts ZIP archives to disk; it removes the temp-dir leak and the dangerous prune-of-unowned-subdirs footgun). Entries whose type has no handler (e.g. `mcp-resource-template`) are skipped. **MCP-delivered scripts are never runnable**: the loader emits no `SkillScript`s, so a bundled script can at most surface as a readable resource (and only if it matches the resource extensions — `.py` is not a default resource extension). The archive `SKILL.md` frontmatter `name` must match the advertised index-entry `name` or the skill is skipped. ZIP extraction is hardened: a `..` path-traversal ("zip-slip") member name raises via `_normalize_archive_member_name` and aborts the whole skill (like the file-count/size limits), and file-count / uncompressed-size (`_read_member_with_limit`) / download-size limits are enforced. Archive behavior is configured with `archive_*` constructor kwargs (`archive_resource_extensions`, `archive_resource_search_depth`, `archive_max_file_count`, `archive_max_size_bytes`, `archive_max_uncompressed_size_bytes`) — Python uses plain kwargs, not a `*Options` object as in .NET. A non-"resource not found" error while downloading an archive propagates (so a failed `CachingSkillsSource` refresh does not overwrite a cached list with a partial result). Unlike .NET's `AgentMcpSkillsSourceOptions.RefreshInterval`, this source has no built-in refresh interval; wrap it in `CachingSkillsSource(..., refresh_interval=...)` for caching/refresh. This is a port of .NET PR #6631; the `FileSkillsSource` `script_extensions`/`resource_extensions` kwargs default to the built-in tuples and treat `None` as "use defaults" and an empty tuple as "discover none" (an empty tuple previously fell back to defaults). `FoundryToolbox.as_skills_provider()` forwards matching `archive_*` kwargs to this source. -- **`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())` 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. +- **`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())` 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, already-entered high-level `Client` or deprecated caller-owned `ClientSession`) or the compatibility-named `session_provider` (`Callable[[], Client | ClientSession]`, resolved on every fetch); providing both/neither raises `ValueError`.** The source borrows the supplied connection and never enters or closes it. Use `session_provider` when an owner replaces its connection over time, so cached `MCPSkill`s keep fetching through the current normalized connection without reading `MCPTool.session`. - **MCP archive digests** - `MCPSkillsSource` verifies each non-null archive `digest` against the decoded ZIP bytes after the download-size guard and before extraction. The accepted form is `sha256:` followed by 64 lowercase hexadecimal characters. Omitted/null digests remain supported; malformed values (including non-string JSON), unsupported algorithms, and mismatches warn and skip only that archive. A successful `CachingSkillsSource` refresh therefore replaces its list without rejected archives; transport errors still propagate and leave the stored cache unchanged. Verification does not apply to lazy `skill-md` entries or their supporting resources. A digest only proves consistency with the index, not that the server or skill content is trustworthy. ### Model Context Protocol (`_mcp.py`) +- **Client lifecycle design** - [ADR 0045](../../../docs/decisions/0045-python-mcp-v2-client-lifecycle.md) + defines the ownership boundary. Both framework-created and caller-supplied `_ClientMCPConnection` instances expose + and route operations through a high-level `mcp.Client`; their `session` remains `None`, and + `is_framework_owned` determines whether Agent Framework may manage the Client lifecycle. Caller-supplied + `_SessionMCPConnection` instances expose and call their `ClientSession` directly as a deprecated low-level + compatibility path. Consequently, `MCPTool.session` is meaningful only for that low-level path. - **`MCPTool` connection state** - The private `_MCPConnection` Protocol is the normalized tools/prompts/catalog surface. `_ClientMCPConnection` and `_SessionMCPConnection` each own one lifecycle responsibility; `None` means - disconnected, and the Client implementation is the single update point for SDK cache policy. `MCPTool.session` - remains a compatibility view/setter; constructor or direct assignment of a session selects the caller-owned - low-level path. + disconnected, and the Client implementation is the single update point for SDK cache policy. - **`MCPStdioTool`** / **`MCPStreamableHTTPTool`** - Supported transport-specific subclasses. **`MCPWebsocketTool`** remains only as a deprecated compatibility symbol because MCP v2 removed WebSocket transport; it cannot create a connection. @@ -227,6 +231,11 @@ The vector store API is experimental under the shared `VECTOR_STORES` feature ID - **Streamable HTTP cookies** - Framework-created clients reject response-cookie persistence with or without a `header_provider`; explicit `Cookie` headers from `static_headers` or a provider remain supported. Caller-provided clients retain their cookie behavior and ownership. Applications requiring cookie persistence must scope clients and MCP sessions to one authenticated principal. Cookie rejection does not isolate MCP protocol sessions or other server-side state. - **`function_invocation_kwargs` and MCP servers** - That dict is shared across every tool in the run, including every attached `MCPTool`, and any name in it reaches a server that declares a matching `inputSchema` property. `header_provider` does not mitigate this — it reads the kwargs without consuming them. To keep a credential out of tool arguments, source it outside `function_invocation_kwargs`: read a `ContextVar` inside the provider (this still allows a different value per request), configure a custom `http_client`, or use `env` for `MCPStdioTool`. - **Sampling guardrails** (`sampling_callback`) - Passing `client=` advertises `SamplingCapability` so the server can send `sampling/createMessage`. Because remote servers are untrusted (confused-deputy risk), the default `sampling_callback` is **deny-by-default** and applies, in order: a per-session rate limit (`sampling_max_requests`, default `_DEFAULT_SAMPLING_MAX_REQUESTS`), an approval gate (`sampling_approval_callback`), and a `maxTokens` cap (`sampling_max_tokens`, default `_DEFAULT_SAMPLING_MAX_TOKENS`). The approval callback (constructor arg on all subclasses; exported type alias `SamplingApprovalCallback`) receives the raw `CreateMessageRequestParams`, may be sync or async, and must return truthy to approve. When it is `None` (the default) every sampling request is denied; pass `lambda params: True` to restore legacy auto-approve as an explicit opt-in. Requests and denials are logged at WARNING (content is not logged). The per-session counter resets in `_reset_session_state`. +- **MCP response caching** - High-level Clients use SDK response caching for tool/prompt catalogs and resource reads + regardless of whether Agent Framework or the caller owns the Client, honoring modern server `ttlMs` / + `cacheScope` hints. Legacy peers default to zero TTL, caller-supplied `ClientSession` connections stay uncached, + and reconnect or effective header-identity changes replace a framework-owned Client's per-Client cache. Agent + Framework does not expose shared-store or cache-mode configuration. - **MCP Tasks extension** - MCP Python SDK 2.2 does not implement the 2026 `io.modelcontextprotocol/tasks` runtime. Tools that advertise `execution.taskSupport == "required"` are skipped with a warning; optional and task-free tools continue through ordinary `tools/call`. Restoration of the experimental Tasks API and removed sample is tracked in microsoft/agent-framework#8245. ### File Access Harness (`_harness/_file_access.py`) diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index d5b613efc15..3f891fe1dbf 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -595,10 +595,10 @@ async def read_resource(self, uri: str) -> types.ReadResourceResult: class _ClientMCPConnection: """MCP connection backed by a high-level Client.""" - def __init__(self, client: Client) -> None: + def __init__(self, client: Client, *, is_framework_owned: bool) -> None: self.client: Client | None = client self.session: ClientSession | None = None - self.is_framework_owned = True + self.is_framework_owned = is_framework_owned self._client = client async def call_tool( @@ -707,7 +707,7 @@ def _as_mcp_connection( # pyright: ignore[reportUnusedFunction] from mcp import Client as MCPClient if isinstance(client, MCPClient): - return _ClientMCPConnection(client) + return _ClientMCPConnection(client, is_framework_owned=False) return _SessionMCPConnection(client) @@ -725,6 +725,11 @@ def _as_mcp_connection( # pyright: ignore[reportUnusedFunction] "it remains supported for at least twelve months before becoming eligible for removal. " "MCP servers should call LLM provider APIs directly." ) +_MCP_SESSION_DEPRECATION_MESSAGE = ( + "`MCPTool.session` is deprecated and will be removed in vNext. " + "Use a high-level `mcp.Client` via `mcp_client=`. " + "See https://github.com/microsoft/agent-framework/issues/9452." +) # A user-supplied gate invoked before each server-initiated sampling request is # forwarded to the chat client. It receives the raw ``CreateMessageRequestParams`` @@ -1072,9 +1077,9 @@ class MCPTool: MCPStdioTool or MCPStreamableHTTPTool. Caching: - Framework-owned modern connections honor server-provided ``ttlMs`` and ``cacheScope`` hints through the - MCP SDK's per-Client response cache. Legacy servers and caller-supplied ``ClientSession`` connections remain - uncached under the default zero-TTL policy. + Modern high-level ``Client`` connections honor server-provided ``ttlMs`` and ``cacheScope`` hints through + the MCP SDK's per-Client response cache. Legacy servers and caller-supplied ``ClientSession`` connections + remain uncached under the default zero-TTL policy. Examples: See the subclass documentation for usage examples: @@ -1094,6 +1099,7 @@ def __init__( parse_tool_results: Callable[[types.CallToolResult], str | list[Content]] | None = None, load_prompts: bool = True, parse_prompt_results: Callable[[types.GetPromptResult], str] | None = None, + mcp_client: Client | None = None, session: ClientSession | None = None, request_timeout: int | None = None, client: SupportsChatGetResponse | None = None, @@ -1163,20 +1169,35 @@ def __init__( asyncio.Queue[tuple[str, bool, bool, bool, asyncio.Future[None], asyncio.Future[bool]]] | None ) = None self._lifecycle_owner_task: asyncio.Task[None] | None = None - self._connection: _MCPConnection | None = _SessionMCPConnection(session) if session is not None else None + + if mcp_client is not None and session is not None: + raise ValueError("`mcp_client` and `session` are mutually exclusive. If possible, prefer `mcp_client`.") + if mcp_client is not None: + self._connection = _ClientMCPConnection(mcp_client, is_framework_owned=False) + elif session is not None: + self._connection = _SessionMCPConnection(session) + else: + self._connection = None + self.request_timeout = request_timeout self.client = client self.sampling_approval_callback = sampling_approval_callback self.sampling_max_tokens = sampling_max_tokens self.sampling_max_requests = sampling_max_requests self._sampling_deprecation_warned = False - if ( + wrapper_sampling_configured = ( client is not None or sampling_approval_callback is not None or sampling_max_tokens != _DEFAULT_SAMPLING_MAX_TOKENS or sampling_max_requests != _DEFAULT_SAMPLING_MAX_REQUESTS - ): + ) + if wrapper_sampling_configured: self._warn_sampling_deprecated(stacklevel=4) + if mcp_client is not None or session is not None: + logger.warning( + "MCPTool sampling options are ignored for a caller-owned connection; " + "configure sampling policy on the supplied Client or ClientSession." + ) self._sampling_request_count = 0 self._functions: list[FunctionTool] = [] self._function_load_callback: _MCPFunctionLoadCallback | None = None @@ -1206,17 +1227,28 @@ def __str__(self) -> str: @property def session(self) -> ClientSession | None: - """Return the low-level session for compatibility and advanced use.""" + """Return the deprecated caller-supplied low-level session. + + .. deprecated:: + Use a high-level ``mcp.Client`` via ``mcp_client=``. + """ + warnings.warn(_MCP_SESSION_DEPRECATION_MESSAGE, DeprecationWarning, stacklevel=2) return self._connection.session if self._connection is not None else None @session.setter def session(self, value: ClientSession | None) -> None: - """Replace the connection with a caller-owned session compatibility path.""" + """Replace the deprecated caller-owned low-level session compatibility path. + + .. deprecated:: + Use a high-level ``mcp.Client`` via ``mcp_client=``. + """ connection = self._connection - if connection is not None and connection.is_framework_owned: - if value is connection.session: - return - raise RuntimeError("Cannot replace the session while its framework-owned MCP Client is connected.") + if connection is not None and connection.client is not None: + raise ValueError( + "Cannot assign `MCPTool.session` while the tool uses a high-level MCP Client. " + "Create a separate tool for the deprecated ClientSession compatibility path." + ) + warnings.warn(_MCP_SESSION_DEPRECATION_MESSAGE, DeprecationWarning, stacklevel=2) self._connection = _SessionMCPConnection(value) if value is not None else None def _mcp_base_span_attributes(self) -> dict[str, Any]: @@ -1787,12 +1819,19 @@ async def _listen_capability_list_changes(self, mcp_client: Client) -> None: return # v2 introduces `mcp_client.listen`, but messages are teed via the message_handler passed in - # in the constructor anyway, so we check for the feature availability an fallback to legacy behaviour + # during Client construction, so framework-owned Clients can fall back to legacy notifications. initial_context = mcp_client.listen(tools_list_changed=tools_changed, prompts_list_changed=prompts_changed) try: initial_subscription = await initial_context.__aenter__() except ListenNotSupportedError: - logger.debug("Listen not supported, falling back to legacy behaviour.") + connection = self._connection + if connection is not None and connection.client is mcp_client and not connection.is_framework_owned: + logger.warning( + "Automatic legacy catalog refresh is unavailable for a caller-owned Client; " + "configure legacy notification handling when constructing the Client." + ) + else: + logger.debug("Listen not supported, falling back to legacy behaviour.") return async def refresh_catalogs() -> None: @@ -2314,7 +2353,6 @@ async def _connect_on_owner( sampling_callback=self.sampling_callback, # pyright: ignore[reportDeprecated] ) ) - session = mcp_client.session except (Exception, asyncio.CancelledError) as ex: cancelled, cleanup_error = await self._close_and_check_cancelled(ex) if cancelled: @@ -2347,7 +2385,8 @@ async def _connect_on_owner( ) as init_span: init_span.set_attribute(OtelAttr.MCP_PROTOCOL_VERSION, mcp_client.protocol_version) self._set_server_capabilities(mcp_client.server_capabilities) - self._ping_available = session.initialize_result is not None + # initialize_result exists only for legacy negotiation; modern MCP has no ping. + self._ping_available = mcp_client.session.initialize_result is not None except (Exception, asyncio.CancelledError) as ex: cancelled, cleanup_error = await self._close_and_check_cancelled(ex) if cancelled: @@ -2373,7 +2412,9 @@ async def _connect_on_owner( error_msg, inner_exception=ex if isinstance(ex, Exception) else None, ) from ex - self._connection = _ClientMCPConnection(mcp_client) + self._connection = _ClientMCPConnection(mcp_client, is_framework_owned=True) + elif connection.client is not None: + self._set_server_capabilities(connection.client.server_capabilities) elif connection.session is not None: session = connection.session try: @@ -3579,6 +3620,7 @@ def __init__( load_prompts: bool = True, parse_prompt_results: Callable[[types.GetPromptResult], str] | None = None, request_timeout: int | None = None, + mcp_client: Client | None = None, session: ClientSession | None = None, description: str | None = None, approval_mode: (Literal["always_require", "never_require"] | MCPSpecificApproval | None) = None, @@ -3627,8 +3669,14 @@ def __init__( access the ``.functions`` list after connecting and set ``result_parser`` on individual ``FunctionTool`` instances. request_timeout: The default timeout in seconds for all requests. - session: An existing MCP client session to use. The caller retains ownership; - closing or resetting this wrapper does not close or replace that session. + mcp_client: An already-entered high-level MCP ``Client`` to use. The caller retains ownership; + closing or resetting this wrapper does not close or replace that client. Mutually exclusive + with ``session``. Sampling options configured on this wrapper do not modify the supplied Client. + The supplied Client also owns its transport and authentication configuration, so it cannot be + combined with this wrapper's ``header_provider``. + session: A deprecated low-level MCP ``ClientSession`` compatibility path. Prefer ``mcp_client`` for new + usage. The caller retains ownership; closing or resetting this wrapper does not close or replace + that session. Sampling options configured on this wrapper do not modify the supplied session. description: The description of the tool. approval_mode: The approval mode for the tool. This can be: - "always_require": The tool always requires approval before use. @@ -3703,6 +3751,7 @@ def __init__( always_load=always_load, tool_name_prefix=tool_name_prefix, additional_properties=additional_properties, + mcp_client=mcp_client, session=session, client=client, load_tools=load_tools, @@ -3786,6 +3835,7 @@ def __init__( load_prompts: bool = True, parse_prompt_results: Callable[[types.GetPromptResult], str] | None = None, request_timeout: int | None = None, + mcp_client: Client | None = None, session: ClientSession | None = None, description: str | None = None, approval_mode: (Literal["always_require", "never_require"] | MCPSpecificApproval | None) = None, @@ -3837,8 +3887,12 @@ def __init__( access the ``.functions`` list after connecting and set ``result_parser`` on individual ``FunctionTool`` instances. request_timeout: The default timeout in seconds for all requests. - session: An existing MCP client session to use. The caller retains ownership; - closing or resetting this wrapper does not close or replace that session. + mcp_client: An already-entered high-level MCP ``Client`` to use. The caller retains ownership; + closing or resetting this wrapper does not close or replace that client. Mutually exclusive + with ``session``. Sampling options configured on this wrapper do not modify the supplied Client. + session: A deprecated low-level MCP ``ClientSession`` compatibility path. Prefer ``mcp_client`` for new + usage. The caller retains ownership; closing or resetting this wrapper does not close or replace + that session. Sampling options configured on this wrapper do not modify the supplied session. description: The description of the tool. approval_mode: The approval mode for the tool. This can be: - "always_require": The tool always requires approval before use. @@ -3973,6 +4027,12 @@ def __init__( ``structuredContent`` are present. See :data:`MCPToolResultContentMode`. kwargs: Additional keyword arguments (accepted for backward compatibility but not used). """ + if mcp_client is not None and header_provider is not None: + raise ValueError( + "`header_provider` cannot be used in combination with a user-supplied `mcp_client`; " + "configure per-request headers on the supplied Client's transport." + ) + super().__init__( name=name, description=description, @@ -3982,6 +4042,7 @@ def __init__( always_load=always_load, tool_name_prefix=tool_name_prefix, additional_properties=additional_properties, + mcp_client=mcp_client, session=session, client=client, load_tools=load_tools, @@ -4229,7 +4290,8 @@ async def _prepare_for_run(self, kwargs: Mapping[str, Any]) -> None: def _bind_session_headers(self, headers: Mapping[str, str]) -> None: self._session_headers = dict(headers) self._session_header_identity = _mcp_header_identity(headers) - self._session_header_session = self.session + connection = self._connection + self._session_header_session = connection.session if connection is not None else None def _stage_session_headers(self, headers: Mapping[str, str], kwargs: Mapping[str, Any]) -> None: self._pending_session_headers = dict(headers) @@ -4278,8 +4340,8 @@ async def _ensure_session_identity( ) -> None: identity = _mcp_header_identity(headers) connection = self._connection - if connection is not None and not connection.is_framework_owned: - if self._session_header_identity is None or self._session_header_session is not self.session: + if isinstance(connection, _SessionMCPConnection): + if self._session_header_identity is None or self._session_header_session is not connection.session: raise _mcp_tool_execution_exception( "MCP header identity is unknown for a caller-supplied session; " "use a separate framework-managed tool instance." diff --git a/python/packages/core/agent_framework/_skills.py b/python/packages/core/agent_framework/_skills.py index 144b96d441f..8e323145f0b 100644 --- a/python/packages/core/agent_framework/_skills.py +++ b/python/packages/core/agent_framework/_skills.py @@ -4593,21 +4593,21 @@ def _resolve_mcp_connection_provider( client: Client | ClientSession | None, session_provider: Callable[[], Client | ClientSession] | None, ) -> Callable[[], _MCPConnection]: - """Normalize the two MCP connection inputs into a single session resolver. + """Normalize the two MCP connection inputs into one connection provider. 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`). + always returns it; a provider is resolved on every call so a reconnecting + owner can supply its current high-level Client or low-level ClientSession. + The ``session_provider`` name is retained for compatibility. Args: client: A fixed MCP client or session, or ``None``. - session_provider: A callable returning the current MCP client session, - or ``None``. + session_provider: A callable returning the current high-level Client or + low-level ClientSession, or ``None``. Returns: - A callable that returns the MCP client or session to use. + A callable returning the normalized MCP connection used for resource reads. Raises: ValueError: If both or neither of *client* and *session_provider* are @@ -4727,17 +4727,15 @@ def __init__( 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: 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). + client: A fixed, already-entered high-level MCP Client or a deprecated + caller-owned ClientSession used to fetch resources on demand. + MCPSkill borrows the connection and never enters or closes it. 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. + session_provider: Compatibility-named callable returning the current + high-level Client or low-level ClientSession, resolved on every + fetch. Use it when the owner may replace the underlying connection; + MCPSkill borrows each returned connection and never enters or closes it. Raises: ValueError: If both or neither of *client* and *session_provider* are @@ -4746,7 +4744,7 @@ def __init__( self._frontmatter = frontmatter self._skill_md_uri = skill_md_uri self._skill_root_uri = self._compute_skill_root_uri(skill_md_uri) - self._session_provider = _resolve_mcp_connection_provider(client, session_provider) + self._connection_provider = _resolve_mcp_connection_provider(client, session_provider) self._content: str | None = None @property @@ -4770,7 +4768,7 @@ async def get_content(self) -> str: if self._content is not None: return self._content - result = await self._session_provider().read_resource(self._skill_md_uri) + result = await self._connection_provider().read_resource(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}'.") @@ -4800,7 +4798,7 @@ async def get_resource(self, name: str) -> SkillResource | None: uri = self._skill_root_uri + normalized try: - result = await self._session_provider().read_resource(uri) + result = await self._connection_provider().read_resource(uri) except Exception as ex: if _is_mcp_resource_not_found(ex): logger.debug("MCP resource '%s' not available: %s", uri, ex) @@ -5084,7 +5082,7 @@ class _ArchiveEntryLoader: def __init__( self, - session_provider: Callable[[], _MCPConnection], + connection_provider: Callable[[], _MCPConnection], *, resource_extensions: tuple[str, ...] | None, resource_search_depth: int, @@ -5092,7 +5090,7 @@ def __init__( max_size_bytes: int, max_uncompressed_size_bytes: int, ) -> None: - self._session_provider = session_provider + self._connection_provider = connection_provider self._resource_extensions: tuple[str, ...] = ( resource_extensions if resource_extensions is not None else DEFAULT_RESOURCE_EXTENSIONS ) @@ -5164,7 +5162,7 @@ async def _download(self, entry: _McpSkillIndexEntry) -> tuple[bytes, str | None while reading the archive resource is re-raised. """ try: - result = await self._session_provider().read_resource(cast(str, entry.url)) + result = await self._connection_provider().read_resource(cast(str, entry.url)) except Exception as ex: if _is_mcp_resource_not_found(ex): logger.debug("Archive resource '%s' for skill '%s' not available: %s", entry.url, entry.name, ex) @@ -5408,12 +5406,13 @@ class MCPSkillsSource(SkillsSource): Examples: .. code-block:: python - from mcp import Client, ClientSession + from mcp import Client - source = MCPSkillsSource(client=session) - # `context` is normally supplied by SkillsProvider at runtime. - context = SkillsSourceContext(agent=agent) - skills = await source.get_skills(context) + async with Client("https://example.com/mcp") as client: + source = MCPSkillsSource(client=client) + # `context` is normally supplied by SkillsProvider at runtime. + context = SkillsSourceContext(agent=agent) + skills = await source.get_skills(context) """ _INDEX_URI: Final[str] = "skill://index.json" @@ -5436,19 +5435,18 @@ def __init__( Provide **exactly one** of *client* or *session_provider*. Args: - client: A fixed MCP client or clientsession 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). + client: A fixed, already-entered high-level MCP Client or a deprecated + caller-owned ClientSession connected to a server that exposes + Agent Skills resources. MCPSkillsSource borrows the connection; + it never enters or closes it. 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. + session_provider: Compatibility-named callable returning the current + high-level Client or low-level ClientSession. It is resolved on + every discovery and on-demand fetch, so cached skills can follow + a replacement connection without reading ``MCPTool.session``. + MCPSkillsSource borrows every returned connection and never enters + or closes it. archive_resource_extensions: Allowed file extensions for resources discovered in ``archive``-type skills. When ``None`` (the default), the file-source default set (``.md``, ``.json``, ``.yaml``, ``.yml``, ``.csv``, ``.xml``, @@ -5470,9 +5468,9 @@ def __init__( ValueError: If both or neither of *client* and *session_provider* are provided. """ - self._session_provider = _resolve_mcp_connection_provider(client, session_provider) + self._connection_provider = _resolve_mcp_connection_provider(client, session_provider) self._archive_loader = _ArchiveEntryLoader( - self._session_provider, + self._connection_provider, resource_extensions=archive_resource_extensions, resource_search_depth=archive_resource_search_depth, max_file_count=archive_max_file_count, @@ -5542,7 +5540,7 @@ async def _try_read_index(self) -> _McpSkillIndex | None: absent, empty, or malformed. """ try: - result = await self._session_provider().read_resource(self._INDEX_URI) + result = await self._connection_provider().read_resource(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) @@ -5561,8 +5559,8 @@ async def _try_read_index(self) -> _McpSkillIndex | None: logger.warning("Failed to parse skill://index.json JSON document.", exc_info=True) return None - def _current_client(self) -> Client | ClientSession: - connection = self._session_provider() + def _current_connection_source(self) -> Client | ClientSession: + connection = self._connection_provider() if connection.client is not None: return connection.client if connection.session is not None: @@ -5600,7 +5598,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, session_provider=self._current_client) + return MCPSkill(frontmatter=fm, skill_md_uri=entry.url, session_provider=self._current_connection_source) # endregion diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index fc836e17b3f..888bcf2990f 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -3859,7 +3859,7 @@ def provider(kwargs: dict[str, Any]) -> dict[str, str]: sdk_client.call_tool = AsyncMock( return_value=types.CallToolResult(content=[types.TextContent(type="text", text="Hello!")]) ) - server._connection = _ClientMCPConnection(sdk_client) + server._connection = _ClientMCPConnection(sdk_client, is_framework_owned=True) await server.load_tools() load_tool = server.functions[1] load_context = FunctionInvocationContext( @@ -4370,6 +4370,36 @@ async def unsupported_listen() -> AsyncGenerator[Any]: assert prompt_load_count == 2 +async def test_caller_owned_client_warns_when_legacy_catalog_refresh_is_caller_managed( + caplog: pytest.LogCaptureFixture, +) -> None: + from mcp.client.subscriptions import ListenNotSupportedError + + @contextlib.asynccontextmanager + async def unsupported_listen() -> AsyncGenerator[Any]: + raise ListenNotSupportedError("2025-11-25") + yield None # pragma: no cover + + capabilities = types.ServerCapabilities(tools=types.ToolsCapability(list_changed=True)) + sdk_client = _mock_sdk_client(capabilities=capabilities, protocol_version="2025-11-25") + sdk_client.listen = Mock(return_value=unsupported_listen()) + sdk_client.list_tools = AsyncMock(return_value=types.ListToolsResult(tools=[])) + tool = MCPStdioTool( + name="test", + command="unused", + mcp_client=sdk_client, + load_prompts=False, + ) + + with caplog.at_level(logging.WARNING, logger="agent_framework"): + await tool.connect() + + assert "Automatic legacy catalog refresh is unavailable for a caller-owned Client" in caplog.text + assert tool._capability_list_subscription_task is None + + await tool.close() + + async def test_mcp_tool_cleans_up_when_catalog_subscription_setup_fails() -> None: @contextlib.asynccontextmanager async def rejected_listen() -> AsyncGenerator[Any]: @@ -4642,6 +4672,34 @@ def test_mcp_tool_each_sampling_option_warns(sampling_option: dict[str, Any]): assert len(warning_info) == 1 +@pytest.mark.parametrize( + "connection", + [ + {"mcp_client": Mock()}, + {"session": Mock(spec=ClientSession)}, + ], +) +def test_mcp_tool_warns_when_sampling_options_are_ignored_for_caller_owned_connection( + connection: dict[str, Any], + caplog: pytest.LogCaptureFixture, +) -> None: + with ( + caplog.at_level(logging.WARNING, logger="agent_framework"), + pytest.warns(DeprecationWarning, match="MCP sampling"), + ): + MCPStdioTool( + name="test", + command="unused", + client=AsyncMock(), + sampling_approval_callback=_approve, + sampling_max_tokens=512, + sampling_max_requests=2, + **connection, + ) + + assert "sampling options are ignored for a caller-owned connection" in caplog.text + + async def test_mcp_tool_sampling_callback_denies_by_default(): """Sampling is denied when no approval callback is configured (safe default).""" tool = MCPStdioTool(name="test_tool", command="python") @@ -5366,20 +5424,25 @@ async def test_connect_retains_and_close_clears_sdk_client() -> None: await tool.connect() try: connection = tool._connection - tool.session = tool.session + with pytest.warns(DeprecationWarning, match=r"MCPTool\.session.*removed in vNext"): + assert tool.session is None assert tool._connection is connection - with pytest.raises(RuntimeError, match="framework-owned MCP Client"): + with pytest.raises(ValueError, match="uses a high-level MCP Client"): tool.session = sdk_client.session + with pytest.raises(ValueError, match="uses a high-level MCP Client"): + tool.session = None await tool.connect(reset=True) reset_connection = tool._connection assert reset_connection is not None assert reset_connection.client is sdk_client - assert tool.session is None + with pytest.warns(DeprecationWarning, match=r"MCPTool\.session.*removed in vNext"): + assert tool.session is None finally: await tool.close() assert tool._connection is None - assert tool.session is None + with pytest.warns(DeprecationWarning, match=r"MCPTool\.session.*removed in vNext"): + assert tool.session is None async def test_call_tool_uses_sdk_client_for_framework_owned_connection() -> None: @@ -8001,6 +8064,240 @@ async def call_tool( assert call_count == 1 +@pytest.mark.parametrize( + ("mode", "expected_version"), + [ + ("auto", "2026-07-28"), + ("legacy", "2025-11-25"), + ], +) +async def test_mcp_tool_reuses_supplied_client(mode: str, expected_version: str) -> None: + from mcp import Client + from mcp.server import Server, ServerRequestContext + + async def list_tools( + _ctx: ServerRequestContext[Any], + _params: types.PaginatedRequestParams | None, + ) -> types.ListToolsResult: + return types.ListToolsResult( + result_type="complete", + tools=[ + types.Tool( + name="greet", + input_schema={"type": "object", "properties": {}}, + ) + ], + ) + + async def call_tool( + _ctx: ServerRequestContext[Any], + params: types.CallToolRequestParams, + ) -> types.CallToolResult: + assert params.name == "greet" + return types.CallToolResult( + result_type="complete", + content=[types.TextContent(type="text", text="Hello!")], + is_error=False, + ) + + server = Server( + "test-server", + on_list_tools=list_tools, + on_call_tool=call_tool, + ) + + async with Client(server, mode=mode) as client: + session = client.session + assert client.protocol_version == expected_version + + client_list_tools = AsyncMock(wraps=client.list_tools) + client_call_tool = AsyncMock(wraps=client.call_tool) + + with ( + patch.object(client, "list_tools", client_list_tools), + patch.object(client, "call_tool", client_call_tool), + ): + wrapper = MCPStdioTool( + name="test", + command="unused", + mcp_client=client, + load_prompts=False, + ) + + with patch.object(wrapper, "get_mcp_client", side_effect=AssertionError("Unexpected transport")): + async with wrapper: + assert wrapper._connection is not None + assert wrapper._connection.client is client + assert wrapper._connection.is_framework_owned is False + assert wrapper.session is None + assert wrapper._server_capabilities == client.server_capabilities + assert [function.name for function in wrapper.functions] == ["greet"] + assert _mcp_result_to_text(await wrapper.call_tool("greet")) == "Hello!" + + assert wrapper._connection is not None + assert wrapper._connection.client is client + assert wrapper._connection.is_framework_owned is False + + client_list_tools.assert_awaited() + client_call_tool.assert_awaited_once() + + # The wrapper has closed, but the caller-owned Client must still work. + result = await client.call_tool("greet") + assert isinstance(result.content[0], types.TextContent) + assert result.content[0].text == "Hello!" + assert client.session is session + + +def test_mcp_transport_tools_preserve_supplied_client_ownership() -> None: + client = Mock() + + tools = [ + MCPStdioTool( + name="stdio", + command="unused", + mcp_client=client, + load_tools=False, + load_prompts=False, + ), + MCPStreamableHTTPTool( + name="http", + url="https://example.com/mcp", + mcp_client=client, + load_tools=False, + load_prompts=False, + ), + ] + + for tool in tools: + assert tool._connection is not None + assert tool._connection.client is client + assert tool._connection.session is None + assert tool._connection.is_framework_owned is False + + +def test_mcp_session_getter_warns_for_low_level_compatibility_path() -> None: + session = Mock(spec=ClientSession) + tool = MCPStdioTool( + name="test", + command="unused", + session=session, + load_tools=False, + load_prompts=False, + ) + + with pytest.warns(DeprecationWarning, match=r"MCPTool\.session.*removed in vNext"): + assert tool.session is session + + +@pytest.mark.parametrize("replacement", [None, Mock(spec=ClientSession)]) +def test_mcp_session_setter_rejects_client_backed_connection_without_mutating_state( + replacement: ClientSession | None, +) -> None: + client = Mock() + tool = MCPStdioTool( + name="test", + command="unused", + mcp_client=client, + load_tools=False, + load_prompts=False, + ) + connection = tool._connection + subscription_task = Mock() + reload_task = Mock() + tool.is_connected = True + tool._tools_loaded = True + tool._prompts_loaded = True + tool._capability_list_subscription_task = subscription_task + tool._pending_reload_tasks = {reload_task} + + with pytest.raises(ValueError, match="uses a high-level MCP Client"): + tool.session = replacement + + assert tool._connection is connection + assert tool.is_connected is True + assert tool._tools_loaded is True + assert tool._prompts_loaded is True + assert tool._capability_list_subscription_task is subscription_task + assert tool._pending_reload_tasks == {reload_task} + + +def test_mcp_streamable_http_rejects_header_provider_with_supplied_client() -> None: + with pytest.raises(ValueError, match="header_provider.*mcp_client"): + MCPStreamableHTTPTool( + name="test", + url="https://example.com/mcp", + mcp_client=Mock(), + header_provider=lambda _: {"Authorization": "Bearer token"}, + load_tools=False, + load_prompts=False, + ) + + +def test_mcp_tool_rejects_supplied_client_and_session() -> None: + with pytest.raises(ValueError, match="mutually exclusive"): + MCPStdioTool( + name="test", + command="unused", + mcp_client=Mock(), + session=Mock(spec=ClientSession), + load_tools=False, + load_prompts=False, + ) + + +async def test_mcp_tool_rejects_unentered_supplied_client() -> None: + from mcp import Client + from mcp.server import Server + + client = Client(Server("test-server")) + wrapper = MCPStdioTool( + name="test", + command="unused", + mcp_client=client, + load_tools=False, + load_prompts=False, + ) + + with pytest.raises(RuntimeError, match="Client must be used within an async context manager"): + await wrapper.connect() + + +async def test_mcp_tool_reset_preserves_supplied_client() -> None: + from mcp import Client + from mcp.server import Server + + async with Client(Server("test-server")) as client: + session = client.session + wrapper = MCPStdioTool( + name="test", + command="unused", + mcp_client=client, + load_tools=False, + load_prompts=False, + ) + + await wrapper.connect() + connection = wrapper._connection + assert connection is not None + assert connection.client is client + assert connection.is_framework_owned is False + assert wrapper._server_capabilities == client.server_capabilities + + await wrapper.connect(reset=True) + + reset_connection = wrapper._connection + assert reset_connection is not None + assert reset_connection is connection + assert reset_connection.client is client + assert reset_connection.is_framework_owned is False + assert wrapper._server_capabilities == client.server_capabilities + + await wrapper.close() + + assert wrapper._connection is connection + assert client.session is session + + @pytest.mark.parametrize( ("mode", "expected_version"), [ @@ -8100,19 +8397,22 @@ async def test_replacing_supplied_session_preserves_caller_ownership() -> None: load_prompts=False, ) - tool.session = None - tool.session = replacement_session + with pytest.warns(DeprecationWarning, match=r"MCPTool\.session.*removed in vNext"): + tool.session = None + with pytest.warns(DeprecationWarning, match=r"MCPTool\.session.*removed in vNext"): + tool.session = replacement_session with patch.object(tool, "get_mcp_client", side_effect=AssertionError("Unexpected transport")): await tool.connect(reset=True) assert tool._connection is not None assert tool._connection.is_framework_owned is False - assert tool.session is replacement_session + with pytest.warns(DeprecationWarning, match=r"MCPTool\.session.*removed in vNext"): + assert tool.session is replacement_session await tool.close() assert tool._connection is not None assert tool._connection.is_framework_owned is False - assert tool.session is replacement_session + assert tool._connection.session is replacement_session async def test_connect_reinitializes_existing_session_and_loads_tools_and_prompts() -> None: @@ -8745,7 +9045,7 @@ async def connect(self): # type: ignore[override] # pyrefly: ignore[bad-overri sdk_client.call_tool = AsyncMock( return_value=types.CallToolResult(content=[types.TextContent(type="text", text="Hello!")]) ) - self._connection = _ClientMCPConnection(sdk_client) + self._connection = _ClientMCPConnection(sdk_client, is_framework_owned=True) self.is_connected = True def get_mcp_client(self): # pyrefly: ignore[bad-override] @@ -8808,7 +9108,7 @@ async def connect(self): # type: ignore[override] # pyrefly: ignore[bad-overri sdk_client.call_tool = AsyncMock( return_value=types.CallToolResult(content=[types.TextContent(type="text", text="Hello!")]) ) - self._connection = _ClientMCPConnection(sdk_client) + self._connection = _ClientMCPConnection(sdk_client, is_framework_owned=True) self.is_connected = True def get_mcp_client(self): # pyrefly: ignore[bad-override] @@ -8851,7 +9151,7 @@ async def connect(self): # type: ignore[override] # pyrefly: ignore[bad-overri sdk_client.call_tool = AsyncMock( return_value=types.CallToolResult(content=[types.TextContent(type="text", text="Hello!")]) ) - self._connection = _ClientMCPConnection(sdk_client) + self._connection = _ClientMCPConnection(sdk_client, is_framework_owned=True) self.is_connected = True def get_mcp_client(self): # pyrefly: ignore[bad-override] @@ -8892,7 +9192,7 @@ async def connect(self): # type: ignore[override] # pyrefly: ignore[bad-overri sdk_client.call_tool = AsyncMock( return_value=types.CallToolResult(content=[types.TextContent(type="text", text="Hello!")]) ) - self._connection = _ClientMCPConnection(sdk_client) + self._connection = _ClientMCPConnection(sdk_client, is_framework_owned=True) self.is_connected = True def get_mcp_client(self): # pyrefly: ignore[bad-override] @@ -9566,7 +9866,7 @@ async def connect(self): # type: ignore[override] # pyrefly: ignore[bad-overri sdk_client.call_tool = AsyncMock( return_value=types.CallToolResult(content=[types.TextContent(type="text", text="Hello!")]) ) - self._connection = _ClientMCPConnection(sdk_client) + self._connection = _ClientMCPConnection(sdk_client, is_framework_owned=True) self.is_connected = True def get_mcp_client(self): # pyrefly: ignore[bad-override] @@ -10269,7 +10569,7 @@ async def connect(self): # type: ignore[override] # pyrefly: ignore[bad-overri sdk_client.call_tool = AsyncMock( return_value=types.CallToolResult(content=[types.TextContent(type="text", text="Hello!")]) ) - self._connection = _ClientMCPConnection(sdk_client) + self._connection = _ClientMCPConnection(sdk_client, is_framework_owned=True) self.is_connected = True def get_mcp_client(self): # pyrefly: ignore[bad-override] @@ -10322,7 +10622,7 @@ async def connect(self, *, reset: bool = False) -> None: ) sdk_client.call_tool = AsyncMock(side_effect=blocking_call_tool) mock_session.send_ping = AsyncMock() - self._connection = _ClientMCPConnection(sdk_client) + self._connection = _ClientMCPConnection(sdk_client, is_framework_owned=True) self.is_connected = True async def _reconnect_for_identity_change(self) -> None: @@ -10680,7 +10980,7 @@ async def connect(self, *, reset: bool = False) -> None: return_value=types.CallToolResult(content=[types.TextContent(type="text", text="sunny")]) ) mock_session.send_ping = AsyncMock() - self._connection = _ClientMCPConnection(sdk_client) + self._connection = _ClientMCPConnection(sdk_client, is_framework_owned=True) self.is_connected = True async def _reconnect_for_identity_change(self) -> None: diff --git a/python/packages/core/tests/core/test_mcp_http_auth.py b/python/packages/core/tests/core/test_mcp_http_auth.py index 2a1ca167572..1374f7b0507 100644 --- a/python/packages/core/tests/core/test_mcp_http_auth.py +++ b/python/packages/core/tests/core/test_mcp_http_auth.py @@ -1217,7 +1217,8 @@ async def test_replacing_supplied_session_invalidates_bound_header_identity() -> ) tool._bind_session_headers({"Authorization": "A"}) - tool.session = replacement_session + with pytest.warns(DeprecationWarning, match=r"MCPTool\.session.*removed in vNext"): + tool.session = replacement_session with pytest.raises(ToolExecutionException, match="identity is unknown"): await tool._ensure_session_identity({"Authorization": "A"}, {}) diff --git a/python/packages/core/tests/core/test_mcp_skills.py b/python/packages/core/tests/core/test_mcp_skills.py index 1f8fa73d450..ac7b122d4ed 100644 --- a/python/packages/core/tests/core/test_mcp_skills.py +++ b/python/packages/core/tests/core/test_mcp_skills.py @@ -675,6 +675,37 @@ async def read_resource( assert calls[1][0] is not None assert calls[0][0] != calls[1][0] + async def test_connection_provider_follows_replacement_high_level_client(self) -> None: + from mcp import Client + from mcp.server import Server, ServerRequestContext + from mcp.types import ReadResourceRequestParams + + def server_with_body(name: str, body: str) -> Server: + async def read_resource( + _ctx: ServerRequestContext[Any], + params: ReadResourceRequestParams, + ) -> ReadResourceResult: + uri = str(params.uri) + if uri == "skill://index.json": + return _make_text_result(SAMPLE_SKILL_INDEX, uri=uri) + return _make_text_result(f"# {name}\n{body}", uri=uri) + + return Server(name, on_read_resource=read_resource) + + async with ( + Client(server_with_body("old-skills", "old body")) as old_client, + Client(server_with_body("new-skills", "new body")) as new_client, + ): + current = {"client": old_client} + source = MCPSkillsSource(session_provider=lambda: current["client"]) + skill = (await source.get_skills(_SOURCE_CTX))[0] + + current["client"] = new_client + content = await skill.get_content() + + assert "new body" in content + assert "old body" not in content + @pytest.mark.parametrize( "uri", [ diff --git a/python/packages/core/tests/test_security.py b/python/packages/core/tests/test_security.py index 3ae96006831..57aed63a382 100644 --- a/python/packages/core/tests/test_security.py +++ b/python/packages/core/tests/test_security.py @@ -6933,7 +6933,7 @@ async def test_apply_mcp_security_labels_uses_high_level_client_connection(self) ), SimpleNamespace(tools=[], next_cursor=None), ] - mcp_tool._connection = _ClientMCPConnection(sdk_client) + mcp_tool._connection = _ClientMCPConnection(sdk_client, is_framework_owned=True) await apply_mcp_security_labels(mcp_tool) diff --git a/python/packages/devui/agent_framework_devui/_executor.py b/python/packages/devui/agent_framework_devui/_executor.py index f64a61964ec..3e601955721 100644 --- a/python/packages/devui/agent_framework_devui/_executor.py +++ b/python/packages/devui/agent_framework_devui/_executor.py @@ -149,65 +149,6 @@ def _track_approval_request(self, event: dict[str, Any], conversation_id: str | } logger.debug("Tracked approval request: %s for function: %s", request_id, fc.get("name", "unknown")) - async def _ensure_mcp_connections(self, agent: Any) -> None: - """Ensure MCP tool connections are healthy before agent execution. - - This is a workaround for an Agent Framework bug where MCP tool connections - can become stale (underlying streams closed) but is_connected remains True. - This happens when HTTP streaming responses end and GeneratorExit propagates. - - This method detects stale connections and reconnects them. It's designed to - be a no-op once the Agent Framework fixes this issue upstream. - - Args: - agent: Agent object that may have MCP tools - """ - if not hasattr(agent, "mcp_tools"): - return - - for mcp_tool in agent.mcp_tools: - if not getattr(mcp_tool, "is_connected", False): - continue - - tool_name = getattr(mcp_tool, "name", "unknown") - - try: - # Check if underlying write stream is closed - session = getattr(mcp_tool, "session", None) - if session is None: - continue - - write_stream = getattr(session, "_write_stream", None) - if write_stream is None: - continue - - # Detect stale connection: is_connected=True but stream is closed - is_closed = getattr(write_stream, "_closed", False) - if not is_closed: - continue # Connection is healthy - - # Stale connection detected - reconnect - logger.warning(f"MCP tool '{tool_name}' has stale connection (stream closed), reconnecting...") - - # Clean up old connection - try: - if hasattr(mcp_tool, "close"): - await mcp_tool.close() - except Exception as close_err: - logger.debug(f"Error closing stale MCP tool '{tool_name}': {close_err}") - # Force reset state - mcp_tool.is_connected = False - mcp_tool.session = None - - # Reconnect - if hasattr(mcp_tool, "connect"): - await mcp_tool.connect() - logger.info(f"MCP tool '{tool_name}' reconnected successfully") - - except Exception as e: - # If detection fails, log and continue - let it fail naturally during execution - logger.debug(f"Error checking MCP tool '{tool_name}' connection: {e}") - async def discover_entities(self) -> list[EntityInfo]: """Discover all available entities. @@ -395,11 +336,6 @@ async def _execute_agent( else: logger.debug(f"Executing agent with multimodal Message: {type(user_message)}") - # Workaround for MCP tool stale connection bug (GitHub issue pending) - # When HTTP streaming ends, GeneratorExit can close MCP stdio streams - # but is_connected stays True. Detect and reconnect before execution. - await self._ensure_mcp_connections(agent) - # Agent must have run() method - use stream=True for streaming if hasattr(agent, "run") and callable(agent.run): # Capture the stream reference so we can call get_final_response() diff --git a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/README.md b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/README.md index 48b462ac3ed..a3f18afae64 100644 --- a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/README.md +++ b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/README.md @@ -51,7 +51,7 @@ export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317" > likely reason a Toolbox skill fails to load, and the failure actively misleads you: connecting to > the toolbox and **discovering** skills both succeed (`skill://index.json` is toolbox metadata, which > needs no role), so the skill is advertised to the model exactly as expected. Only the first -> `load_skill` fails — with `McpError('Failed to read resource.')` — because reading a skill's *body* +> `load_skill` fails — with `MCPError('Failed to read resource.')` — because reading a skill's *body* > dereferences the project-level skill resource, which does require the role. The toolbox answers > with a bare JSON-RPC `-32603` and no `data`, so nothing in the error names the cause. > diff --git a/python/samples/02-agents/mcp/README.md b/python/samples/02-agents/mcp/README.md index a409a8d6cf8..73c0843b893 100644 --- a/python/samples/02-agents/mcp/README.md +++ b/python/samples/02-agents/mcp/README.md @@ -13,10 +13,22 @@ The Model Context Protocol (MCP) is an open standard for connecting AI agents to | **Agent as MCP Server** | [`agent_as_mcp_server.py`](agent_as_mcp_server.py) | Shows how to expose an Agent Framework agent as an MCP server that other AI applications can connect to | | **API Key Authentication** | [`mcp_api_key_auth.py`](mcp_api_key_auth.py) | Demonstrates API key authentication with MCP servers using `header_provider`, runtime invocation kwargs, and a command-line API key argument | | **GitHub Integration with PAT** | [`mcp_github_pat.py`](mcp_github_pat.py) | Demonstrates connecting to GitHub's MCP server using Personal Access Token (PAT) authentication | - - | **Progressive Disclosure** | [`mcp_progressive_disclosure.py`](mcp_progressive_disclosure.py) | Demonstrates `use_progressive_disclosure`, `always_load`, `allowed_tools`, and prefixed `list_mcp_tools` / `load_tool` / `unload_tool` names. `load_tool` and `unload_tool` can accept one tool name or multiple names. Self-spawns a stdio MCP child server | -| **Sampling Approval** | [`mcp_sampling_approval.py`](mcp_sampling_approval.py) | Demonstrates gating server-initiated `sampling/createMessage` requests with a `sampling_approval_callback`, plus the `sampling_max_tokens` and `sampling_max_requests` guardrails. MCP sampling is denied by default | + +The previous long-running Tasks sample was removed during the MCP 2.x migration. +Agent Framework does not currently implement the MCP Tasks extension because +the supported Python SDK does not yet expose that runtime. Future Tasks support +is tracked in [#9215](https://github.com/microsoft/agent-framework/issues/9215). + +## Protocol behavior + +- Framework-created connections use SDK auto negotiation: modern servers use + `server/discover`, while older servers fall back to `initialize`. +- The high-level SDK client owns MRTR retries and modern server-directed + response caching. Agent Framework owns catalog subscription recovery and + refresh around the SDK's event stream. +- Caller-supplied `ClientSession` objects remain caller-owned, low-level, and + uncached; they do not gain automatic MRTR. ## Prerequisites @@ -31,8 +43,3 @@ Run `mcp_api_key_auth.py` with the MCP API key as the first command-line argumen For `mcp_github_pat.py`: - `GITHUB_PAT` - Your GitHub Personal Access Token (create at https://github.com/settings/tokens) - -For `mcp_long_running_task.py` (uses Azure OpenAI via Entra-ID): -- Run `az login` once -- `AZURE_OPENAI_ENDPOINT` - your Azure OpenAI resource endpoint, e.g. `https://.openai.azure.com/` -- `AZURE_OPENAI_CHAT_MODEL` (or `AZURE_OPENAI_MODEL`) - the deployment name (e.g. `gpt-4o-mini`) diff --git a/python/samples/02-agents/mcp/mcp_sampling_approval.py b/python/samples/02-agents/mcp/mcp_sampling_approval.py deleted file mode 100644 index 75b32c13af8..00000000000 --- a/python/samples/02-agents/mcp/mcp_sampling_approval.py +++ /dev/null @@ -1,78 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio - -from agent_framework import Agent, MCPStreamableHTTPTool -from agent_framework.openai import OpenAIChatClient -from dotenv import load_dotenv -from mcp import types - -# Load environment variables from .env file -load_dotenv() - -""" -MCP Sampling Approval Example - -MCP servers can send the client a ``sampling/createMessage`` request, asking the -client to run an LLM completion on the server's behalf. Because remote MCP -servers are untrusted third parties, forwarding these server-controlled prompts -to your chat client without review is a confused-deputy risk: a malicious server -could exfiltrate context, force tool calls, or burn through your token budget. - -For that reason Agent Framework **denies MCP sampling by default**. To allow it, -pass a ``sampling_approval_callback`` to the MCP tool. The callback receives the -raw ``CreateMessageRequestParams`` and returns ``True`` to approve or ``False`` -to deny. It may be synchronous or asynchronous, so you can implement a -human-in-the-loop prompt, a policy check, or an audit log. - -Two further guardrails apply to approved requests: -- ``sampling_max_tokens`` caps the server-requested ``maxTokens``. -- ``sampling_max_requests`` limits how many sampling requests a single session - may make. - -To restore the legacy "always approve" behavior (only do this for servers you -trust), pass ``sampling_approval_callback=lambda params: True``. -""" - - -async def approve_sampling(params: types.CreateMessageRequestParams) -> bool: - """Human-in-the-loop approval gate for server-initiated sampling. - - Shows the server-supplied system prompt and messages, then asks the user to - approve or deny. Returning ``False`` rejects the request. - """ - print("\n--- MCP server requested a sampling/createMessage ---") - if params.system_prompt: - print(f"System prompt: {params.system_prompt}") - for message in params.messages: - text = getattr(message.content, "text", message.content) - print(f"{message.role}: {text}") - answer = await asyncio.to_thread(input, "Approve this sampling request? [y/N]: ") - return answer.strip().lower() in {"y", "yes"} - - -async def main() -> None: - """Run an agent against an MCP server with a sampling approval gate.""" - async with Agent( - client=OpenAIChatClient(), - name="Agent", - instructions="You are a helpful assistant. Use your MCP tool when answering the user's question.", - tools=MCPStreamableHTTPTool( - name="MCP tool", - description="MCP tool description.", - url="", - # Passing ``client`` enables sampling; the approval callback gates it. - client=OpenAIChatClient(), - sampling_approval_callback=approve_sampling, - sampling_max_tokens=2048, - sampling_max_requests=5, - ), - ) as agent: - query = "Use your MCP tool to help answer this question." - print(f"User: {query}") - result = await agent.run(query) - print(f"Agent: {result.text}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox_skills.py b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox_skills.py index 9d669e5af82..bcaafe55c9e 100644 --- a/python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox_skills.py +++ b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox_skills.py @@ -2,16 +2,11 @@ import asyncio import os -from collections.abc import Generator -import httpx2 -from agent_framework import Agent, MCPSkillsSource, SkillsProvider, ToolApprovalMiddleware -from agent_framework.foundry import FoundryChatClient -from azure.core.credentials import TokenCredential -from azure.identity import AzureCliCredential, get_bearer_token_provider +from agent_framework import Agent, SkillsProvider, ToolApprovalMiddleware +from agent_framework.foundry import FoundryChatClient, FoundryToolbox +from azure.identity import AzureCliCredential from dotenv import load_dotenv -from mcp.client.session import ClientSession -from mcp.client.streamable_http import streamable_http_client # Load environment variables from .env file load_dotenv() @@ -19,10 +14,10 @@ """ Foundry Chat Client with Toolbox-Hosted Skills -Discover Agent Skills served by a Microsoft Foundry Toolbox MCP endpoint -and inject them into a ``FoundryChatClient`` agent via ``MCPSkillsSource``. -The toolbox's discovery document (``skill://index.json``) is read once at -startup; SKILL.md bodies are fetched on demand as the agent uses them. +Discover Agent Skills served by a Microsoft Foundry Toolbox MCP endpoint and +inject them into a ``FoundryChatClient`` agent. ``FoundryToolbox`` owns the MCP +connection and authentication, while its skills provider resolves +identity-scoped resources through the current connection. Prerequisites: - A Microsoft Foundry project with a toolbox that exposes @@ -34,54 +29,30 @@ """ -class _BearerAuth(httpx2.Auth): - """Attach a fresh Foundry bearer token to every request.""" - - def __init__(self, credential: TokenCredential) -> None: - self._get_token = get_bearer_token_provider(credential, "https://ai.azure.com/.default") - - def auth_flow(self, request: httpx2.Request) -> Generator[httpx2.Request, httpx2.Response, None]: - request.headers["Authorization"] = f"Bearer {self._get_token()}" - yield request - - async def main() -> None: """Example showing toolbox-hosted MCP skills for a Foundry Chat Client agent.""" credential = AzureCliCredential() - - # HTTP client that signs every request with a fresh Foundry bearer token - # and advertises the toolbox preview feature flag, plus the MCP streamable - # HTTP transport that uses it. - async with ( - httpx2.AsyncClient( - auth=_BearerAuth(credential), - timeout=httpx2.Timeout(30.0, read=300.0), - follow_redirects=True, - ) as http_client, - streamable_http_client( - url=os.environ["FOUNDRY_TOOLBOX_MCP_SERVER_URL"], - http_client=http_client, - ) as (read, write), - ClientSession(read, write) as session, - ): - await session.initialize() - - # Discover skills served by the toolbox and inject them as a context provider. - skills_provider = SkillsProvider(MCPSkillsSource(client=session)) - - async with Agent( - client=FoundryChatClient(credential=credential), - name="ToolboxMCPSkillsAgent", - instructions="You are a helpful assistant. Use available skills to answer the user.", - context_providers=[skills_provider], - middleware=[ToolApprovalMiddleware(auto_approval_rules=[SkillsProvider.all_tools_auto_approval_rule])], - ) as agent: - query = input("User: ").strip() # noqa: ASYNC250 - if not query: - return - session = agent.create_session() - response = await agent.run(query, session=session) - print(f"Assistant: {response.text}") + toolbox = FoundryToolbox( + credential, + url=os.environ["FOUNDRY_TOOLBOX_MCP_SERVER_URL"], + load_tools=False, + ) + skills_provider = toolbox.as_skills_provider() + + async with Agent( + client=FoundryChatClient(credential=credential), + name="ToolboxMCPSkillsAgent", + instructions="You are a helpful assistant. Use available skills to answer the user.", + tools=[toolbox], + context_providers=[skills_provider], + middleware=[ToolApprovalMiddleware(auto_approval_rules=[SkillsProvider.all_tools_auto_approval_rule])], + ) as agent: + query = input("User: ").strip() # noqa: ASYNC250 + if not query: + return + session = agent.create_session() + response = await agent.run(query, session=session) + print(f"Assistant: {response.text}") if __name__ == "__main__": diff --git a/python/samples/02-agents/skills/mcp_based_skill/mcp_based_skill.py b/python/samples/02-agents/skills/mcp_based_skill/mcp_based_skill.py index 65f5a10317f..98dc6b6e34b 100644 --- a/python/samples/02-agents/skills/mcp_based_skill/mcp_based_skill.py +++ b/python/samples/02-agents/skills/mcp_based_skill/mcp_based_skill.py @@ -11,8 +11,7 @@ from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv -from mcp.client.session import ClientSession -from mcp.client.streamable_http import streamable_http_client +from mcp import Client """ MCP-Based Agent Skills @@ -42,15 +41,13 @@ async def main() -> None: print("Discovering MCP-based skills") print("-" * 60) - # 1. Connect to the MCP server over streamable HTTP. - async with streamable_http_client(url=mcp_url) as (read, write), ClientSession(read, write) as session: - await session.initialize() - + # 1. Connect to the MCP server with the high-level MCP Client. + async with Client(mcp_url) as mcp_client: # 2. Build a SkillsProvider that discovers skills over MCP. # MCPSkillsSource reads skill://index.json and creates one # MCPSkill per skill-md entry; SKILL.md bodies are fetched # on demand via resources/read. - skills_provider = SkillsProvider(MCPSkillsSource(client=session)) + skills_provider = SkillsProvider(MCPSkillsSource(client=mcp_client)) # 3. Run the agent. client = FoundryChatClient( diff --git a/python/samples/04-hosting/mcp/README.md b/python/samples/04-hosting/mcp/README.md index 12e19922d68..3c34adc3eaa 100644 --- a/python/samples/04-hosting/mcp/README.md +++ b/python/samples/04-hosting/mcp/README.md @@ -7,8 +7,8 @@ There are two common ways to build that: native tool schema, validates or parses its arguments, routes the selected tool name, and returns its result. 2. Declare callable tools and let a higher-level server generate the list and - call handlers from those declarations. FastMCP follows this model by deriving - tool schemas and argument parsing from Python function signatures. + call handlers from those declarations. `MCPServer` follows this model by + deriving tool schemas and argument parsing from Python function signatures. Choose between them based on the other MCP features you want to expose and how much control you need over the server, schema, validation, lifecycle, and @@ -44,18 +44,18 @@ Use this when the application needs full control over a custom MCP contract. uv run manual_app.py ``` -### 2. FastMCP server +### 2. MCPServer -[`fastmcp_app.py`](fastmcp_app.py) keeps the same two conversion functions but -replaces the low-level server setup with FastMCP. FastMCP derives and validates +[`mcpserver_app.py`](mcpserver_app.py) keeps the same two conversion functions but +replaces the low-level server setup with `MCPServer`. `MCPServer` derives and validates the tool schema from the decorated `run_agent(...)` function and owns the streamable HTTP server. Use this when a normal Python function signature fully describes the MCP tool. -FastMCP keeps its generated schema and argument parsing aligned. +`MCPServer` keeps its generated schema and argument parsing aligned. ```bash -uv run fastmcp_app.py +uv run mcpserver_app.py ``` ### 3. Agent-derived tool @@ -65,7 +65,7 @@ derives the native tool name and description from the agent, owns the configured argument schema, runs the agent, and applies the same conversion boundary. Use this when one Agent Framework agent should be represented as one generated -MCP tool. Unlike the FastMCP sample, this adapter derives the contract from the +MCP tool. Unlike the `MCPServer` sample, this adapter derives the contract from the agent and its adapter configuration rather than a decorated function signature. ```bash @@ -106,8 +106,8 @@ uv run workflow_app.py | Concept | Responsibility | Used by | |---|---|---| -| `mcp_to_run(...)` | Converts validated MCP arguments into Agent Framework messages and selected chat options. | `manual_app.py`, `fastmcp_app.py` | -| `mcp_from_run(...)` | Converts a completed agent response into MCP result content blocks. | `manual_app.py`, `fastmcp_app.py` | +| `mcp_to_run(...)` | Converts validated MCP arguments into Agent Framework messages and selected chat options. | `manual_app.py`, `mcpserver_app.py` | +| `mcp_from_run(...)` | Converts a completed agent response into MCP result content blocks. | `manual_app.py`, `mcpserver_app.py` | | `AgentMCPTool` | Derives one native MCP tool from an agent and keeps schema, execution, and conversion aligned. | `agent_app.py`, `session_app.py` | | `WorkflowMCPTool` | Derives one native MCP tool from a workflow start executor and converts workflow outputs. | `workflow_app.py` | @@ -130,8 +130,11 @@ dependency set using PEP 723 inline script metadata. ## Common behavior -- **No framework choice:** the package does not select FastMCP, Starlette, +- **No framework choice:** the package does not select `MCPServer`, Starlette, Uvicorn, stdio, or streamable HTTP. +- **Protocol eras:** the supported MCP 2.x SDK serves modern requests and + retains legacy `initialize` compatibility. Modern server-to-client input uses + MRTR. - **Chat options:** only explicitly selected MCP arguments are passed to the model client. The samples expose `reasoning_effort` as an example, but any option valid for the agent can be exposed. @@ -139,8 +142,9 @@ dependency set using PEP 723 inline script metadata. multimodal input content blocks. The samples do not present an application-specific image schema as protocol behavior. - **Streaming:** streamable HTTP can carry multiple MCP messages, but a tool - call still produces one final `CallToolResult`. Progress notifications and - experimental deferred tasks remain application-owned protocol features. + call still produces one final `CallToolResult`. The SDK does not expose the + current Tasks extension runtime, so these samples do not implement deferred + tasks. - **Authentication:** the local endpoints are intentionally unauthenticated. MCP transport session identifiers are not user authorization. Production servers must authenticate and authorize before loading tenant or user state. diff --git a/python/samples/04-hosting/mcp/fastmcp_app.py b/python/samples/04-hosting/mcp/mcpserver_app.py similarity index 80% rename from python/samples/04-hosting/mcp/fastmcp_app.py rename to python/samples/04-hosting/mcp/mcpserver_app.py index fc253e1b37a..7d34e933eb4 100644 --- a/python/samples/04-hosting/mcp/fastmcp_app.py +++ b/python/samples/04-hosting/mcp/mcpserver_app.py @@ -7,13 +7,13 @@ # "mcp>=2.2.0,<3", # ] # /// -# Run with: uv run fastmcp_app.py +# Run with: uv run mcpserver_app.py # Copyright (c) Microsoft. All rights reserved. -"""Host an Agent Framework agent with FastMCP and the conversion helpers. +"""Host an Agent Framework agent with MCPServer and the conversion helpers. -FastMCP derives the native MCP tool schema from the decorated function +MCPServer derives the native MCP tool schema from the decorated function signature. The Agent Framework hosting package only converts the validated arguments and completed agent response at the protocol boundary. @@ -53,13 +53,13 @@ @asynccontextmanager async def lifespan(_server: MCPServer[None]) -> AsyncGenerator[None]: - """Close the model credential when the FastMCP server stops.""" + """Close the model credential when the MCP server stops.""" async with credential: yield server = MCPServer( - name="agent-framework-hosting-fastmcp-sample", + name="agent-framework-hosting-mcpserver-sample", instructions="Expose an Agent Framework agent as an MCP tool.", lifespan=lifespan, ) @@ -74,7 +74,7 @@ async def run_agent( task: str, reasoning_effort: Literal["low", "medium", "high"] | None = None, ) -> types.CallToolResult: - """Run the agent with FastMCP-validated arguments.""" + """Run the agent with MCPServer-validated arguments.""" arguments: dict[str, object] = {"task": task} if reasoning_effort is not None: arguments["reasoning_effort"] = reasoning_effort @@ -89,4 +89,11 @@ async def run_agent( if __name__ == "__main__": - server.run(transport="streamable-http", host="127.0.0.1", port=8000, streamable_http_path="/mcp", stateless_http=True, json_response=True) + server.run( + transport="streamable-http", + host="127.0.0.1", + port=8000, + streamable_http_path="/mcp", + stateless_http=True, + json_response=True, + )