From 86584815684a3ea877217b358a869a31ebf16269 Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Mon, 19 Oct 2026 14:11:57 +0200 Subject: [PATCH 1/8] Added mcp_client supplied workflow sample + tightened ownership checks in constructor --- python/packages/core/agent_framework/_mcp.py | 58 +++++++++++++++---- .../agents/mcp_client_lifecycle.py | 43 ++++++++++++++ 2 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 python/samples/03-workflows/agents/mcp_client_lifecycle.py diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 5e8fc27acb6..d467270257b 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -3831,7 +3831,7 @@ class MCPStreamableHTTPTool(MCPTool): def __init__( self, name: str, - url: str, + url: str | None = None, *, tool_name_prefix: str | None = None, load_tools: bool = True, @@ -3864,15 +3864,20 @@ def __init__( """Initialize the MCP streamable HTTP tool. Note: - The arguments are used to create a streamable HTTP client using the - new ``mcp.client.streamable_http.streamable_http_client`` API. - If an ``httpx2.AsyncClient`` is provided via ``http_client``, it will be used as the underlying transport - client. - Otherwise, the tool creates and manages a default client without response-cookie persistence. + Supply either ``url`` for a framework-owned high-level Client, or an already-entered + ``mcp_client`` for a caller-owned high-level Client. The deprecated ``session`` path + remains caller-owned and may continue to be combined with ``url`` for compatibility. + + In framework-owned URL mode, the arguments are used to create a streamable HTTP client + using the new ``mcp.client.streamable_http.streamable_http_client`` API. If an + ``httpx2.AsyncClient`` is provided via ``http_client``, it will be used as the underlying + transport client. Otherwise, the tool creates and manages a default client without + response-cookie persistence. Args: name: The name of the tool. - url: The URL of the MCP server. + url: The URL of the MCP server for framework-owned Client construction. Mutually exclusive + with ``mcp_client``. May be omitted when supplying ``mcp_client`` or the deprecated ``session``. Keyword Args: tool_name_prefix: Optional prefix to prepend to exposed MCP function names. @@ -3893,7 +3898,10 @@ def __init__( request_timeout: The default timeout in seconds for all requests. 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. + with ``url`` and ``session``. ``request_timeout``, ``terminate_on_close``, ``http_client``, + ``static_headers``, and ``header_provider`` configure framework-owned HTTP construction and + cannot be supplied with ``mcp_client``. 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. @@ -4031,11 +4039,32 @@ 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: + 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 and url 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." + "`url` and `mcp_client` are mutually exclusive; configure the endpoint on the supplied Client." ) + if mcp_client is None and session is None and url is None: + raise ValueError("`url` is required when neither `mcp_client` nor `session` supplies a connection.") + if mcp_client is not None: + conflicting_options = [ + option + for option, is_configured in ( + ("request_timeout", request_timeout is not None), + ("terminate_on_close", terminate_on_close is not None), + ("http_client", http_client is not None), + ("static_headers", static_headers is not None), + ("header_provider", header_provider is not None), + ) + if is_configured + ] + if conflicting_options: + formatted_options = ", ".join(f"`{option}`" for option in conflicting_options) + raise ValueError( + f"`mcp_client` cannot be combined with framework-owned HTTP options: {formatted_options}. " + "Configure these settings on the supplied Client instead." + ) super().__init__( name=name, @@ -4093,6 +4122,8 @@ def _mcp_base_span_attributes(self) -> dict[str, Any]: attrs = super()._mcp_base_span_attributes() attrs[OtelAttr.NETWORK_TRANSPORT] = "tcp" attrs[OtelAttr.NETWORK_PROTOCOL_NAME] = "http" + if self.url is None: + return attrs try: from httpx import URL @@ -4119,6 +4150,11 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: Returns: An async context manager for the streamable HTTP client transport. """ + if self.url is None: + raise RuntimeError( + "Internal invariant violated: MCPStreamableHTTPTool requires `url` to create a framework-owned Client." + ) + from httpx2 import URL, AsyncClient, Timeout self._promote_pending_session_headers() diff --git a/python/samples/03-workflows/agents/mcp_client_lifecycle.py b/python/samples/03-workflows/agents/mcp_client_lifecycle.py new file mode 100644 index 00000000000..d579033dc31 --- /dev/null +++ b/python/samples/03-workflows/agents/mcp_client_lifecycle.py @@ -0,0 +1,43 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os + +from agent_framework import Agent, MCPStreamableHTTPTool +from agent_framework.foundry import FoundryChatClient +from agent_framework_orchestrations import SequentialBuilder +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from mcp import Client + +load_dotenv() + + +async def main() -> None: + chat_client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AzureCliCredential(), + ) + + MS_LEARN_MCP_URL = "https://learn.microsoft.com/api/mcp" + + async with Client(server=MS_LEARN_MCP_URL) as mcp_client: + research_agent = Agent( + client=chat_client, + name="research_agent", + tools=MCPStreamableHTTPTool(name="ms_learn", mcp_client=mcp_client), + instructions="You are a Microsoft research agent backing your research with the MS learn docs" + ) + writer_agent = Agent( + client=chat_client, + name="writer_agent", + instructions="You are a technical writer that explains concepts clearly, concisely and focuses on the key points." + ) + workflow = SequentialBuilder(participants=[research_agent, writer_agent]).build().as_agent() + response = await workflow.run("Explain to me how to setup the python Agent Framework") + print(response.text) + + +if __name__ == "__main__": + asyncio.run(main()) From 284a065329a86460840be7fcc3d468fa97576a46 Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Mon, 19 Oct 2026 14:24:36 +0200 Subject: [PATCH 2/8] Tests, docs and sample edits --- .../0045-python-mcp-v2-client-lifecycle.md | 11 +- python/packages/core/AGENTS.md | 6 + python/packages/core/tests/core/test_mcp.py | 135 ++++++++++++++++-- .../core/tests/core/test_mcp_observability.py | 10 ++ python/samples/03-workflows/README.md | 1 + .../agents/mcp_client_lifecycle.py | 36 ++++- 6 files changed, 186 insertions(+), 13 deletions(-) diff --git a/docs/decisions/0045-python-mcp-v2-client-lifecycle.md b/docs/decisions/0045-python-mcp-v2-client-lifecycle.md index 765daa5d134..9211b9846c9 100644 --- a/docs/decisions/0045-python-mcp-v2-client-lifecycle.md +++ b/docs/decisions/0045-python-mcp-v2-client-lifecycle.md @@ -82,6 +82,15 @@ For **caller-supplied** connections, the caller owns: - constructing and closing a **supplied** low-level `ClientSession`; - the transport, authentication, callbacks, and other configuration already captured by either supplied object. +For `MCPStreamableHTTPTool`, the constructor makes that ownership boundary explicit: + +- `url` without `mcp_client` means Agent Framework constructs and owns the high-level Client and HTTP transport; +- `mcp_client` without `url` means the caller owns endpoint and transport configuration plus the entered Client; +- `url` and `mcp_client` are mutually exclusive; +- HTTP construction options (`request_timeout`, `terminate_on_close`, `http_client`, `static_headers`, and + `header_provider`) are rejected with a caller-supplied Client rather than silently ignored; +- the deprecated caller-owned `ClientSession` path retains its historical `url + session` compatibility. + The SDK `Client` owns: - modern negotiation and legacy initialization fallback; @@ -245,4 +254,4 @@ Committed contract tests cover: - [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. +This ADR was accepted through the repository ADR review process. diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index ad8a87b1080..eb5e7125834 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -221,6 +221,12 @@ The vector store API is experimental under the shared `VECTOR_STORES` feature ID - **`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. +- **`MCPStreamableHTTPTool` construction ownership** - Pass `url` alone when Agent Framework should construct and + own the high-level Client and HTTP transport. Pass an already-entered `mcp_client` alone when the caller owns + endpoint configuration, transport settings, and Client lifecycle. `url + mcp_client` is invalid, and + framework-owned HTTP options (`request_timeout`, `terminate_on_close`, `http_client`, `static_headers`, and + `header_provider`) are rejected with `mcp_client` instead of being ignored. The deprecated caller-owned + `ClientSession` path retains `url + session` compatibility. - **Argument allowlist (`_prepare_call_kwargs`)** - Before each `tools/call`, kwargs are filtered to an **allowlist** built from the tool's declared parameters (`inputSchema.properties`) plus any user-configured extras. **The declared half comes from the server's advertised schema**, and runtime kwargs (`FunctionInvocationContext.kwargs`, seeded from `function_invocation_kwargs`) are merged with the model-supplied arguments upstream in `_call_tool_with_runtime_kwargs`, so provenance is gone by the time the filter runs. A runtime kwarg is therefore forwarded whenever the server declares a property of that name, without the model mentioning it — the server, not the caller, decides which runtime kwarg names it receives. A tool that declares no usable `properties` (including schemas with `additionalProperties: true`) forwards only the configured extras. `_MCP_FRAMEWORK_DENYLIST` is a narrow safety net covering only non-serializable framework objects a server *declares* in its schema (those are dropped); it does not generalize to arbitrary caller-chosen names, and explicit extras always win. The reserved `_meta` key is never forwarded as an argument; trusted caller/runtime `_meta` is validated as MCP request metadata, model-supplied `_meta` is discarded in generated MCP functions, and metadata precedence is caller/runtime < OpenTelemetry < tools/list metadata. - **`allowed_tools`** (constructor arg on all `MCPTool` subclasses) - Restricts exposed MCP tools by raw remote MCP tool identity. Prefixed local names remain accepted only when the raw remote name already matches its normalized form; normalized/local aliases do not authorize a different raw remote name. Configured allow/approval names must identify at most one raw remote name across loaded tools and prompts: a raw name that overlaps another tool's prefixed alias raises `ToolExecutionException`. Discovery validates all pages before publishing new functions; a failed reload retains the previous functions and metadata. The allowlist is also revalidated when exposing functions so runtime changes cannot select an ambiguous name. If multiple raw remote tool names map to the same local function name, tool loading raises `ToolExecutionException` instead of first-one-wins shadowing. - **Progressive MCP disclosure** (`use_progressive_disclosure`, `always_load`) - When enabled on any `MCPTool` subclass, the initial model-facing surface is loader tools (`list_mcp_tools` / `load_tool` / `unload_tool`, prefixed by `tool_name_prefix` when configured) plus allowed tools selected by `always_load` and tools loaded earlier on the same `MCPTool` instance. `list_mcp_tools` only reports tools that pass `allowed_tools`; filtered tools are not listed or loadable. Loader tool names are reserved in progressive mode: remote MCP tools whose local generated name collides with a loader name are omitted from the initial/listed surface, and explicit `load_tool` calls return a model-visible message pointing callers to `tool_name_prefix` or excluding the colliding tool. `load_tool` accepts one tool name or a list of tool names and uses `FunctionInvocationContext.add_tools(...)` so the selected generated MCP `FunctionTool`s become available on the next function-calling iteration while keeping existing approval mode, argument filtering, header-provider runtime kwargs, result parsing, and OTel. `unload_tool` accepts one dynamically loaded tool name or a list of names and removes them from the live tool list and persisted progressive surface, but it does not remove tools configured in `always_load`. Invalid `always_load` entries are ignored like unmatched `allowed_tools` entries. diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 0f666f5a618..9ff525bef5b 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -8150,6 +8150,12 @@ async def call_tool( def test_mcp_transport_tools_preserve_supplied_client_ownership() -> None: client = Mock() + http_tool = MCPStreamableHTTPTool( + name="http", + mcp_client=client, + load_tools=False, + load_prompts=False, + ) tools = [ MCPStdioTool( @@ -8159,15 +8165,10 @@ def test_mcp_transport_tools_preserve_supplied_client_ownership() -> None: load_tools=False, load_prompts=False, ), - MCPStreamableHTTPTool( - name="http", - url="https://example.com/mcp", - mcp_client=client, - load_tools=False, - load_prompts=False, - ), + http_tool, ] + assert http_tool.url is None for tool in tools: assert tool._connection is not None assert tool._connection.client is client @@ -8175,6 +8176,26 @@ def test_mcp_transport_tools_preserve_supplied_client_ownership() -> None: assert tool._connection.is_framework_owned is False +def test_mcp_streamable_http_rejects_url_with_supplied_client() -> None: + with pytest.raises(ValueError, match=r"`url` and `mcp_client` are mutually exclusive"): + MCPStreamableHTTPTool( + name="test", + url="https://example.com/mcp", + mcp_client=Mock(), + load_tools=False, + load_prompts=False, + ) + + +def test_mcp_streamable_http_requires_connection_source() -> None: + with pytest.raises(ValueError, match=r"`url` is required when neither `mcp_client` nor `session`"): + MCPStreamableHTTPTool( + name="test", + load_tools=False, + load_prompts=False, + ) + + def test_mcp_session_getter_warns_for_low_level_compatibility_path() -> None: session = Mock(spec=ClientSession) tool = MCPStdioTool( @@ -8222,10 +8243,9 @@ def test_mcp_session_setter_rejects_client_backed_connection_without_mutating_st def test_mcp_streamable_http_rejects_header_provider_with_supplied_client() -> None: - with pytest.raises(ValueError, match="header_provider.*mcp_client"): + with pytest.raises(ValueError, match="mcp_client.*header_provider"): MCPStreamableHTTPTool( name="test", - url="https://example.com/mcp", mcp_client=Mock(), header_provider=lambda _: {"Authorization": "Bearer token"}, load_tools=False, @@ -8233,6 +8253,103 @@ def test_mcp_streamable_http_rejects_header_provider_with_supplied_client() -> N ) +@pytest.mark.parametrize( + ("option_name", "option_value"), + [ + ("request_timeout", 30), + ("terminate_on_close", False), + ("http_client", Mock(spec=AsyncClient)), + ("static_headers", {}), + ], +) +def test_mcp_streamable_http_rejects_framework_owned_http_options_with_supplied_client( + option_name: str, + option_value: Any, +) -> None: + with pytest.raises(ValueError) as exc_info: + MCPStreamableHTTPTool( + name="test", + mcp_client=Mock(), + load_tools=False, + load_prompts=False, + **{option_name: option_value}, + ) + + message = str(exc_info.value) + assert "`mcp_client` cannot be combined with framework-owned HTTP options" in message + assert f"`{option_name}`" in message + + +def test_mcp_streamable_http_lists_all_framework_owned_http_options_with_supplied_client() -> None: + with pytest.raises(ValueError) as exc_info: + MCPStreamableHTTPTool( + name="test", + mcp_client=Mock(), + request_timeout=30, + terminate_on_close=False, + http_client=Mock(spec=AsyncClient), + static_headers={}, + header_provider=lambda _: {"Authorization": "******"}, + load_tools=False, + load_prompts=False, + ) + + message = str(exc_info.value) + for option_name in ( + "request_timeout", + "terminate_on_close", + "http_client", + "static_headers", + "header_provider", + ): + assert f"`{option_name}`" in message + + +def test_mcp_streamable_http_preserves_url_with_supplied_session_compatibility() -> None: + session = Mock(spec=ClientSession) + tool = MCPStreamableHTTPTool( + name="test", + url="https://example.com/mcp", + session=session, + load_tools=False, + load_prompts=False, + ) + + assert tool.url == "https://example.com/mcp" + assert tool._connection is not None + assert tool._connection.client is None + assert tool._connection.session is session + assert tool._connection.is_framework_owned is False + + +def test_mcp_streamable_http_rejects_supplied_client_and_session_before_other_conflicts() -> None: + with pytest.raises( + ValueError, + match=r"`mcp_client` and `session` are mutually exclusive\. If possible, prefer `mcp_client`\.", + ): + MCPStreamableHTTPTool( + name="test", + url="https://example.com/mcp", + mcp_client=Mock(), + session=Mock(spec=ClientSession), + request_timeout=30, + load_tools=False, + load_prompts=False, + ) + + +def test_mcp_streamable_http_get_mcp_client_requires_framework_owned_url() -> None: + tool = MCPStreamableHTTPTool( + name="test", + mcp_client=Mock(), + load_tools=False, + load_prompts=False, + ) + + with pytest.raises(RuntimeError, match=r"Internal invariant violated.*requires `url`"): + tool.get_mcp_client() + + def test_mcp_tool_rejects_supplied_client_and_session() -> None: with pytest.raises(ValueError, match="mutually exclusive"): MCPStdioTool( diff --git a/python/packages/core/tests/core/test_mcp_observability.py b/python/packages/core/tests/core/test_mcp_observability.py index 9af3d1c7655..3a1b5cf99ad 100644 --- a/python/packages/core/tests/core/test_mcp_observability.py +++ b/python/packages/core/tests/core/test_mcp_observability.py @@ -462,6 +462,16 @@ def test_mcp_http_tool_http_default_port(): assert attrs[OtelAttr.PORT] == 80 +def test_mcp_http_tool_without_url_omits_server_address_and_port(): + """A caller-owned Client has HTTP transport attributes but no wrapper-owned URL attributes.""" + tool = MCPStreamableHTTPTool(name="test", mcp_client=Mock()) + attrs = tool._mcp_base_span_attributes() + assert attrs[OtelAttr.NETWORK_TRANSPORT] == "tcp" + assert attrs[OtelAttr.NETWORK_PROTOCOL_NAME] == "http" + assert OtelAttr.ADDRESS not in attrs + assert OtelAttr.PORT not in attrs + + # endregion diff --git a/python/samples/03-workflows/README.md b/python/samples/03-workflows/README.md index 628a8cfbbc6..4efbb61809a 100644 --- a/python/samples/03-workflows/README.md +++ b/python/samples/03-workflows/README.md @@ -56,6 +56,7 @@ Write workflows as plain Python async functions — no graph concepts, no execut | Workflow as Agent + HITL | [agents/workflow_as_agent_human_in_the_loop.py](./agents/workflow_as_agent_human_in_the_loop.py) | Extend workflow-as-agent with human-in-the-loop capability | | Workflow as Agent with Session | [agents/workflow_as_agent_with_session.py](./agents/workflow_as_agent_with_session.py) | Use AgentSession to maintain conversation history across workflow-as-agent invocations | | Workflow as Agent kwargs | [agents/workflow_as_agent_kwargs.py](./agents/workflow_as_agent_kwargs.py) | Pass custom context (data, user tokens) via kwargs through workflow.as_agent() to @tool tools | +| MCP Client Lifecycle | [agents/mcp_client_lifecycle.py](./agents/mcp_client_lifecycle.py) | Keep a caller-owned MCP Client alive for an entire sequential workflow run | ### checkpoint diff --git a/python/samples/03-workflows/agents/mcp_client_lifecycle.py b/python/samples/03-workflows/agents/mcp_client_lifecycle.py index d579033dc31..b012616ea3b 100644 --- a/python/samples/03-workflows/agents/mcp_client_lifecycle.py +++ b/python/samples/03-workflows/agents/mcp_client_lifecycle.py @@ -12,8 +12,23 @@ load_dotenv() +""" +Sample: Use a caller-owned MCP Client for the complete lifetime of a workflow run. + +The researcher uses the public Microsoft Learn MCP server, then the technical writer +turns the research into a concise answer. The caller creates and enters the high-level +MCP Client, passes it to `MCPStreamableHTTPTool`, and keeps it open until the sequential +workflow finishes. + +Prerequisites: +- FOUNDRY_PROJECT_ENDPOINT must be set to the Microsoft Foundry project endpoint. +- FOUNDRY_MODEL must be set to a model deployment available in that project. +- Authentication uses AzureCliCredential; run `az login` before executing the sample. +""" + async def main() -> None: + # 1) Create the shared chat client used by both workflow agents. chat_client = FoundryChatClient( project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], model=os.environ["FOUNDRY_MODEL"], @@ -22,22 +37,37 @@ async def main() -> None: MS_LEARN_MCP_URL = "https://learn.microsoft.com/api/mcp" + # 2) Enter the caller-owned MCP Client before creating the MCP tool. async with Client(server=MS_LEARN_MCP_URL) as mcp_client: research_agent = Agent( client=chat_client, name="research_agent", tools=MCPStreamableHTTPTool(name="ms_learn", mcp_client=mcp_client), - instructions="You are a Microsoft research agent backing your research with the MS learn docs" + instructions="You are a Microsoft research agent backing your research with Microsoft Learn docs.", ) writer_agent = Agent( client=chat_client, name="writer_agent", - instructions="You are a technical writer that explains concepts clearly, concisely and focuses on the key points." + instructions=( + "You are a technical writer who explains concepts clearly, concisely, and focuses on the key points." + ), ) + + # 3) Build and run the workflow while the caller-owned MCP Client remains entered. workflow = SequentialBuilder(participants=[research_agent, writer_agent]).build().as_agent() - response = await workflow.run("Explain to me how to setup the python Agent Framework") + response = await workflow.run("Explain how to set up the Python Agent Framework.") print(response.text) + """ + Sample output: + + A Microsoft Learn-grounded explanation of how to install Agent Framework, + configure a model provider, authenticate, create an agent, and run it. + + The exact response varies by model. The workflow-as-agent response contains + the final technical writer output. + """ + if __name__ == "__main__": asyncio.run(main()) From b322d752306b1b6532988f12a9f5e2fefb54de32 Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Mon, 19 Oct 2026 14:28:30 +0200 Subject: [PATCH 3/8] Adjusted MCPStdioTool --- .../0045-python-mcp-v2-client-lifecycle.md | 13 +- python/packages/core/AGENTS.md | 11 +- python/packages/core/agent_framework/_mcp.py | 57 +++++-- python/packages/core/tests/core/test_mcp.py | 141 ++++++++++++++++-- .../core/tests/core/test_mcp_observability.py | 14 ++ 5 files changed, 199 insertions(+), 37 deletions(-) diff --git a/docs/decisions/0045-python-mcp-v2-client-lifecycle.md b/docs/decisions/0045-python-mcp-v2-client-lifecycle.md index 9211b9846c9..29ad8ffd7df 100644 --- a/docs/decisions/0045-python-mcp-v2-client-lifecycle.md +++ b/docs/decisions/0045-python-mcp-v2-client-lifecycle.md @@ -82,14 +82,11 @@ For **caller-supplied** connections, the caller owns: - constructing and closing a **supplied** low-level `ClientSession`; - the transport, authentication, callbacks, and other configuration already captured by either supplied object. -For `MCPStreamableHTTPTool`, the constructor makes that ownership boundary explicit: - -- `url` without `mcp_client` means Agent Framework constructs and owns the high-level Client and HTTP transport; -- `mcp_client` without `url` means the caller owns endpoint and transport configuration plus the entered Client; -- `url` and `mcp_client` are mutually exclusive; -- HTTP construction options (`request_timeout`, `terminate_on_close`, `http_client`, `static_headers`, and - `header_provider`) are rejected with a caller-supplied Client rather than silently ignored; -- the deprecated caller-owned `ClientSession` path retains its historical `url + session` compatibility. +Transport-specific tool constructors enforce the same ownership boundary. A URL or command selects +framework-owned Client construction; a supplied high-level `mcp.Client` selects caller-owned construction and +lifecycle. Constructor combinations that mix those ownership modes or configure a transport the caller already +owns raise `ValueError` instead of silently ignoring parameters. Temporarily retained legacy constructor and +`ClientSession` combinations remain compatible and will be removed in vNext. The SDK `Client` owns: diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index eb5e7125834..895764eb93d 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -221,12 +221,11 @@ The vector store API is experimental under the shared `VECTOR_STORES` feature ID - **`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. -- **`MCPStreamableHTTPTool` construction ownership** - Pass `url` alone when Agent Framework should construct and - own the high-level Client and HTTP transport. Pass an already-entered `mcp_client` alone when the caller owns - endpoint configuration, transport settings, and Client lifecycle. `url + mcp_client` is invalid, and - framework-owned HTTP options (`request_timeout`, `terminate_on_close`, `http_client`, `static_headers`, and - `header_provider`) are rejected with `mcp_client` instead of being ignored. The deprecated caller-owned - `ClientSession` path retains `url + session` compatibility. +- **MCP transport construction ownership** - For `MCPStreamableHTTPTool`, `url` selects framework-owned + construction; for `MCPStdioTool`, `command` does. Supplying an already-entered `mcp_client` instead makes the + caller responsible for transport configuration and Client lifecycle. Constructors reject mixed ownership modes + and framework-owned transport options with `mcp_client` rather than ignoring them. Deprecated caller-owned + `ClientSession` combinations remain compatible until their vNext removal. - **Argument allowlist (`_prepare_call_kwargs`)** - Before each `tools/call`, kwargs are filtered to an **allowlist** built from the tool's declared parameters (`inputSchema.properties`) plus any user-configured extras. **The declared half comes from the server's advertised schema**, and runtime kwargs (`FunctionInvocationContext.kwargs`, seeded from `function_invocation_kwargs`) are merged with the model-supplied arguments upstream in `_call_tool_with_runtime_kwargs`, so provenance is gone by the time the filter runs. A runtime kwarg is therefore forwarded whenever the server declares a property of that name, without the model mentioning it — the server, not the caller, decides which runtime kwarg names it receives. A tool that declares no usable `properties` (including schemas with `additionalProperties: true`) forwards only the configured extras. `_MCP_FRAMEWORK_DENYLIST` is a narrow safety net covering only non-serializable framework objects a server *declares* in its schema (those are dropped); it does not generalize to arbitrary caller-chosen names, and explicit extras always win. The reserved `_meta` key is never forwarded as an argument; trusted caller/runtime `_meta` is validated as MCP request metadata, model-supplied `_meta` is discarded in generated MCP functions, and metadata precedence is caller/runtime < OpenTelemetry < tools/list metadata. - **`allowed_tools`** (constructor arg on all `MCPTool` subclasses) - Restricts exposed MCP tools by raw remote MCP tool identity. Prefixed local names remain accepted only when the raw remote name already matches its normalized form; normalized/local aliases do not authorize a different raw remote name. Configured allow/approval names must identify at most one raw remote name across loaded tools and prompts: a raw name that overlaps another tool's prefixed alias raises `ToolExecutionException`. Discovery validates all pages before publishing new functions; a failed reload retains the previous functions and metadata. The allowlist is also revalidated when exposing functions so runtime changes cannot select an ambiguous name. If multiple raw remote tool names map to the same local function name, tool loading raises `ToolExecutionException` instead of first-one-wins shadowing. - **Progressive MCP disclosure** (`use_progressive_disclosure`, `always_load`) - When enabled on any `MCPTool` subclass, the initial model-facing surface is loader tools (`list_mcp_tools` / `load_tool` / `unload_tool`, prefixed by `tool_name_prefix` when configured) plus allowed tools selected by `always_load` and tools loaded earlier on the same `MCPTool` instance. `list_mcp_tools` only reports tools that pass `allowed_tools`; filtered tools are not listed or loadable. Loader tool names are reserved in progressive mode: remote MCP tools whose local generated name collides with a loader name are omitted from the initial/listed surface, and explicit `load_tool` calls return a model-visible message pointing callers to `tool_name_prefix` or excluding the colliding tool. `load_tool` accepts one tool name or a list of tool names and uses `FunctionInvocationContext.add_tools(...)` so the selected generated MCP `FunctionTool`s become available on the next function-calling iteration while keeping existing approval mode, argument filtering, header-provider runtime kwargs, result parsing, and OTel. `unload_tool` accepts one dynamically loaded tool name or a list of names and removes them from the live tool list and persisted progressive surface, but it does not remove tools configured in `always_load`. Invalid `always_load` entries are ignored like unmatched `allowed_tools` entries. diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index d467270257b..183c392a941 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -3616,7 +3616,7 @@ class MCPStdioTool(MCPTool): def __init__( self, name: str, - command: str, + command: str | None = None, *, tool_name_prefix: str | None = None, load_tools: bool = True, @@ -3648,13 +3648,18 @@ def __init__( """Initialize the MCP stdio tool. Note: - The arguments are used to create a StdioServerParameters object, - which is then used to create a stdio client. See ``mcp.client.stdio.stdio_client`` + Supply either ``command`` for a framework-owned high-level Client, or an already-entered + ``mcp_client`` for a caller-owned high-level Client. The deprecated ``session`` path + remains caller-owned and may continue to be combined with ``command`` for compatibility. + + In framework-owned command mode, the arguments are used to create a + ``StdioServerParameters`` object and stdio client. See ``mcp.client.stdio.stdio_client`` and ``mcp.client.stdio.stdio_server_parameters`` for more details. Args: name: The name of the tool. - command: The command to run the MCP server. + command: The command to run for framework-owned stdio Client construction. Mutually exclusive + with ``mcp_client``. May be omitted when supplying ``mcp_client`` or the deprecated ``session``. Keyword Args: tool_name_prefix: Optional prefix to prepend to exposed MCP function names. @@ -3675,9 +3680,10 @@ def __init__( request_timeout: The default timeout in seconds for all requests. 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``. + with ``command`` and ``session``. ``request_timeout``, ``args``, ``env``, ``encoding``, and + additional stdio keyword arguments configure framework-owned process construction and cannot be + supplied with ``mcp_client``. 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. @@ -3744,8 +3750,35 @@ def __init__( transports. ``None`` disables the limit. tool_result_content: How to choose model-visible text when both ``content`` and ``structuredContent`` are present. See :data:`MCPToolResultContentMode`. - kwargs: Any extra arguments to pass to the stdio client. + kwargs: Additional arguments for framework-owned ``StdioServerParameters`` construction. """ + 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 and command is not None: + raise ValueError( + "`command` and `mcp_client` are mutually exclusive; configure the process on the supplied Client." + ) + if mcp_client is None and session is None and command is None: + raise ValueError("`command` is required when neither `mcp_client` nor `session` supplies a connection.") + if mcp_client is not None: + conflicting_options = [ + option + for option, is_configured in ( + ("request_timeout", request_timeout is not None), + ("args", args is not None), + ("env", env is not None), + ("encoding", encoding is not None), + ("kwargs", bool(kwargs)), + ) + if is_configured + ] + if conflicting_options: + formatted_options = ", ".join(f"`{option}`" for option in conflicting_options) + raise ValueError( + f"`mcp_client` cannot be combined with framework-owned stdio options: {formatted_options}. " + "Configure these settings on the supplied Client instead." + ) + super().__init__( name=name, description=description, @@ -3779,7 +3812,8 @@ def __init__( def _mcp_base_span_attributes(self) -> dict[str, Any]: attrs = super()._mcp_base_span_attributes() - attrs[OtelAttr.NETWORK_TRANSPORT] = "pipe" + if self.command is not None: + attrs[OtelAttr.NETWORK_TRANSPORT] = "pipe" return attrs def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: @@ -3788,6 +3822,11 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: Returns: An async context manager for the stdio client transport. """ + if self.command is None: + raise RuntimeError( + "Internal invariant violated: MCPStdioTool requires `command` to create a framework-owned Client." + ) + args: dict[str, Any] = { "command": self.command, "args": self.args, diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 9ff525bef5b..032a351b122 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -4386,7 +4386,6 @@ async def unsupported_listen() -> AsyncGenerator[Any]: sdk_client.list_tools = AsyncMock(return_value=types.ListToolsResult(tools=[])) tool = MCPStdioTool( name="test", - command="unused", mcp_client=sdk_client, load_prompts=False, ) @@ -8119,7 +8118,6 @@ async def call_tool( ): wrapper = MCPStdioTool( name="test", - command="unused", mcp_client=client, load_prompts=False, ) @@ -8150,6 +8148,12 @@ async def call_tool( def test_mcp_transport_tools_preserve_supplied_client_ownership() -> None: client = Mock() + stdio_tool = MCPStdioTool( + name="stdio", + mcp_client=client, + load_tools=False, + load_prompts=False, + ) http_tool = MCPStreamableHTTPTool( name="http", mcp_client=client, @@ -8158,16 +8162,11 @@ def test_mcp_transport_tools_preserve_supplied_client_ownership() -> None: ) tools = [ - MCPStdioTool( - name="stdio", - command="unused", - mcp_client=client, - load_tools=False, - load_prompts=False, - ), + stdio_tool, http_tool, ] + assert stdio_tool.command is None assert http_tool.url is None for tool in tools: assert tool._connection is not None @@ -8196,6 +8195,26 @@ def test_mcp_streamable_http_requires_connection_source() -> None: ) +def test_mcp_stdio_rejects_command_with_supplied_client() -> None: + with pytest.raises(ValueError, match=r"`command` and `mcp_client` are mutually exclusive"): + MCPStdioTool( + name="test", + command="python", + mcp_client=Mock(), + load_tools=False, + load_prompts=False, + ) + + +def test_mcp_stdio_requires_connection_source() -> None: + with pytest.raises(ValueError, match=r"`command` is required when neither `mcp_client` nor `session`"): + MCPStdioTool( + name="test", + load_tools=False, + load_prompts=False, + ) + + def test_mcp_session_getter_warns_for_low_level_compatibility_path() -> None: session = Mock(spec=ClientSession) tool = MCPStdioTool( @@ -8217,7 +8236,6 @@ def test_mcp_session_setter_rejects_client_backed_connection_without_mutating_st client = Mock() tool = MCPStdioTool( name="test", - command="unused", mcp_client=client, load_tools=False, load_prompts=False, @@ -8350,18 +8368,115 @@ def test_mcp_streamable_http_get_mcp_client_requires_framework_owned_url() -> No tool.get_mcp_client() -def test_mcp_tool_rejects_supplied_client_and_session() -> None: - with pytest.raises(ValueError, match="mutually exclusive"): +@pytest.mark.parametrize( + ("option_name", "option_value"), + [ + ("request_timeout", 30), + ("args", []), + ("env", {}), + ("encoding", ""), + ("kwargs", {"cwd": "."}), + ], +) +def test_mcp_stdio_rejects_framework_owned_options_with_supplied_client( + option_name: str, + option_value: Any, +) -> None: + kwargs = option_value if option_name == "kwargs" else {option_name: option_value} + + with pytest.raises(ValueError) as exc_info: + MCPStdioTool( + name="test", + mcp_client=Mock(), + load_tools=False, + load_prompts=False, + **kwargs, + ) + + message = str(exc_info.value) + assert "`mcp_client` cannot be combined with framework-owned stdio options" in message + assert f"`{option_name}`" in message + + +def test_mcp_stdio_lists_all_framework_owned_options_with_supplied_client() -> None: + with pytest.raises(ValueError) as exc_info: + MCPStdioTool( + name="test", + mcp_client=Mock(), + request_timeout=30, + args=[], + env={}, + encoding="", + load_tools=False, + load_prompts=False, + cwd=".", + ) + + message = str(exc_info.value) + for option_name in ("request_timeout", "args", "env", "encoding", "kwargs"): + assert f"`{option_name}`" in message + + +def test_mcp_stdio_preserves_command_with_supplied_session_compatibility() -> None: + session = Mock(spec=ClientSession) + tool = MCPStdioTool( + name="test", + command="python", + session=session, + load_tools=False, + load_prompts=False, + ) + + assert tool.command == "python" + assert tool._connection is not None + assert tool._connection.client is None + assert tool._connection.session is session + assert tool._connection.is_framework_owned is False + + +def test_mcp_stdio_allows_session_without_command() -> None: + session = Mock(spec=ClientSession) + tool = MCPStdioTool( + name="test", + session=session, + load_tools=False, + load_prompts=False, + ) + + assert tool.command is None + assert tool._connection is not None + assert tool._connection.session is session + + +def test_mcp_stdio_rejects_supplied_client_and_session_before_other_conflicts() -> None: + with pytest.raises( + ValueError, + match=r"`mcp_client` and `session` are mutually exclusive\. If possible, prefer `mcp_client`\.", + ): MCPStdioTool( name="test", command="unused", mcp_client=Mock(), session=Mock(spec=ClientSession), + request_timeout=30, + args=[], load_tools=False, load_prompts=False, ) +def test_mcp_stdio_get_mcp_client_requires_framework_owned_command() -> None: + tool = MCPStdioTool( + name="test", + mcp_client=Mock(), + load_tools=False, + load_prompts=False, + ) + + with pytest.raises(RuntimeError, match=r"Internal invariant violated.*requires `command`"): + tool.get_mcp_client() + + async def test_mcp_tool_rejects_unentered_supplied_client() -> None: from mcp import Client from mcp.server import Server @@ -8369,7 +8484,6 @@ async def test_mcp_tool_rejects_unentered_supplied_client() -> None: client = Client(Server("test-server")) wrapper = MCPStdioTool( name="test", - command="unused", mcp_client=client, load_tools=False, load_prompts=False, @@ -8387,7 +8501,6 @@ async def test_mcp_tool_reset_preserves_supplied_client() -> None: session = client.session wrapper = MCPStdioTool( name="test", - command="unused", mcp_client=client, load_tools=False, load_prompts=False, diff --git a/python/packages/core/tests/core/test_mcp_observability.py b/python/packages/core/tests/core/test_mcp_observability.py index 3a1b5cf99ad..d26477654c8 100644 --- a/python/packages/core/tests/core/test_mcp_observability.py +++ b/python/packages/core/tests/core/test_mcp_observability.py @@ -438,6 +438,20 @@ def test_mcp_stdio_tool_transport_attributes(): assert OtelAttr.ADDRESS not in attrs +def test_mcp_stdio_tool_with_supplied_client_omits_transport_attributes(): + """A caller-owned Client does not imply that its transport is a local pipe.""" + tool = MCPStdioTool(name="test", mcp_client=Mock()) + attrs = tool._mcp_base_span_attributes() + assert OtelAttr.NETWORK_TRANSPORT not in attrs + + +def test_mcp_stdio_tool_with_command_and_supplied_session_preserves_pipe_transport(): + """The released command plus caller-owned session path retains stdio telemetry.""" + tool = MCPStdioTool(name="test", command="python", session=Mock()) + attrs = tool._mcp_base_span_attributes() + assert attrs[OtelAttr.NETWORK_TRANSPORT] == "pipe" + + def test_mcp_http_tool_transport_attributes(): """MCPStreamableHTTPTool should have tcp transport and URL-based server address/port.""" tool = MCPStreamableHTTPTool(name="test", url="https://api.example.com:8443/mcp") From 73ad8eb4fdf1f186a773197be9d772827cca4522 Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Mon, 19 Oct 2026 14:38:28 +0200 Subject: [PATCH 4/8] Fix caller-owned MCP sampling test setup --- python/packages/core/tests/core/test_mcp.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 032a351b122..010c37a2d6d 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -4675,7 +4675,7 @@ def test_mcp_tool_each_sampling_option_warns(sampling_option: dict[str, Any]): "connection", [ {"mcp_client": Mock()}, - {"session": Mock(spec=ClientSession)}, + {"command": "unused", "session": Mock(spec=ClientSession)}, ], ) def test_mcp_tool_warns_when_sampling_options_are_ignored_for_caller_owned_connection( @@ -4688,7 +4688,6 @@ def test_mcp_tool_warns_when_sampling_options_are_ignored_for_caller_owned_conne ): MCPStdioTool( name="test", - command="unused", client=AsyncMock(), sampling_approval_callback=_approve, sampling_max_tokens=512, From 36812c6d5635c9d2bd666b24b1639958640bdf85 Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Mon, 19 Oct 2026 14:57:20 +0200 Subject: [PATCH 5/8] Address MCP lifecycle review feedback --- python/packages/core/agent_framework/_mcp.py | 4 +- .../core/tests/core/test_mcp_observability.py | 8 ++-- .../agents/mcp_client_lifecycle.py | 43 ++++++++++--------- 3 files changed, 29 insertions(+), 26 deletions(-) diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 183c392a941..7e5e7886d68 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -4159,10 +4159,10 @@ def __init__( def _mcp_base_span_attributes(self) -> dict[str, Any]: attrs = super()._mcp_base_span_attributes() - attrs[OtelAttr.NETWORK_TRANSPORT] = "tcp" - attrs[OtelAttr.NETWORK_PROTOCOL_NAME] = "http" if self.url is None: return attrs + attrs[OtelAttr.NETWORK_TRANSPORT] = "tcp" + attrs[OtelAttr.NETWORK_PROTOCOL_NAME] = "http" try: from httpx import URL diff --git a/python/packages/core/tests/core/test_mcp_observability.py b/python/packages/core/tests/core/test_mcp_observability.py index d26477654c8..4c1ecbfeec9 100644 --- a/python/packages/core/tests/core/test_mcp_observability.py +++ b/python/packages/core/tests/core/test_mcp_observability.py @@ -476,12 +476,12 @@ def test_mcp_http_tool_http_default_port(): assert attrs[OtelAttr.PORT] == 80 -def test_mcp_http_tool_without_url_omits_server_address_and_port(): - """A caller-owned Client has HTTP transport attributes but no wrapper-owned URL attributes.""" +def test_mcp_http_tool_without_url_omits_transport_and_server_attributes(): + """A caller-owned Client has no wrapper-observed transport or server attributes.""" tool = MCPStreamableHTTPTool(name="test", mcp_client=Mock()) attrs = tool._mcp_base_span_attributes() - assert attrs[OtelAttr.NETWORK_TRANSPORT] == "tcp" - assert attrs[OtelAttr.NETWORK_PROTOCOL_NAME] == "http" + assert OtelAttr.NETWORK_TRANSPORT not in attrs + assert OtelAttr.NETWORK_PROTOCOL_NAME not in attrs assert OtelAttr.ADDRESS not in attrs assert OtelAttr.PORT not in attrs diff --git a/python/samples/03-workflows/agents/mcp_client_lifecycle.py b/python/samples/03-workflows/agents/mcp_client_lifecycle.py index b012616ea3b..e990b3e907e 100644 --- a/python/samples/03-workflows/agents/mcp_client_lifecycle.py +++ b/python/samples/03-workflows/agents/mcp_client_lifecycle.py @@ -17,8 +17,8 @@ The researcher uses the public Microsoft Learn MCP server, then the technical writer turns the research into a concise answer. The caller creates and enters the high-level -MCP Client, passes it to `MCPStreamableHTTPTool`, and keeps it open until the sequential -workflow finishes. +MCP Client, passes it to `MCPStreamableHTTPTool`, and keeps both open until the +sequential workflow finishes. Prerequisites: - FOUNDRY_PROJECT_ENDPOINT must be set to the Microsoft Foundry project endpoint. @@ -39,24 +39,27 @@ async def main() -> None: # 2) Enter the caller-owned MCP Client before creating the MCP tool. async with Client(server=MS_LEARN_MCP_URL) as mcp_client: - research_agent = Agent( - client=chat_client, - name="research_agent", - tools=MCPStreamableHTTPTool(name="ms_learn", mcp_client=mcp_client), - instructions="You are a Microsoft research agent backing your research with Microsoft Learn docs.", - ) - writer_agent = Agent( - client=chat_client, - name="writer_agent", - instructions=( - "You are a technical writer who explains concepts clearly, concisely, and focuses on the key points." - ), - ) - - # 3) Build and run the workflow while the caller-owned MCP Client remains entered. - workflow = SequentialBuilder(participants=[research_agent, writer_agent]).build().as_agent() - response = await workflow.run("Explain how to set up the Python Agent Framework.") - print(response.text) + # 3) Enter the MCP wrapper so its background tasks close before the caller-owned Client. + async with MCPStreamableHTTPTool(name="ms_learn", mcp_client=mcp_client) as mcp_tool: + research_agent = Agent( + client=chat_client, + name="research_agent", + tools=mcp_tool, + instructions="You are a Microsoft research agent backing your research with Microsoft Learn docs.", + ) + writer_agent = Agent( + client=chat_client, + name="writer_agent", + instructions=( + "You are a technical writer who explains concepts clearly, concisely, and focuses on the key " + "points." + ), + ) + + # 4) Build and run the workflow while the caller-owned MCP Client and wrapper remain entered. + workflow = SequentialBuilder(participants=[research_agent, writer_agent]).build().as_agent() + response = await workflow.run("Explain how to set up the Python Agent Framework.") + print(response.text) """ Sample output: From 8da2b600431f9e03d91e3471f7e684e5dfb21c9b Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Thu, 22 Oct 2026 14:13:53 +0200 Subject: [PATCH 6/8] PR feedback --- .../agents/mcp_client_lifecycle.py | 59 +++++++++++-------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/python/samples/03-workflows/agents/mcp_client_lifecycle.py b/python/samples/03-workflows/agents/mcp_client_lifecycle.py index e990b3e907e..58de234d44b 100644 --- a/python/samples/03-workflows/agents/mcp_client_lifecycle.py +++ b/python/samples/03-workflows/agents/mcp_client_lifecycle.py @@ -3,7 +3,7 @@ import asyncio import os -from agent_framework import Agent, MCPStreamableHTTPTool +from agent_framework import Agent, AgentResponse, MCPStreamableHTTPTool from agent_framework.foundry import FoundryChatClient from agent_framework_orchestrations import SequentialBuilder from azure.identity import AzureCliCredential @@ -20,6 +20,12 @@ MCP Client, passes it to `MCPStreamableHTTPTool`, and keeps both open until the sequential workflow finishes. +For most applications, passing the `url=` directly to `MCPStreamableHTTPTool` and let +Agent Framework manage the connection is sufficient. Use this caller-owned Client pattern +when the application must configure or reuse the high-level MCP Client, or keep its +connection alive across multiple workflow runs. The caller owns the Client +lifetime, while Agent Framework still owns the wrapper lifecycle. + Prerequisites: - FOUNDRY_PROJECT_ENDPOINT must be set to the Microsoft Foundry project endpoint. - FOUNDRY_MODEL must be set to a model deployment available in that project. @@ -37,29 +43,34 @@ async def main() -> None: MS_LEARN_MCP_URL = "https://learn.microsoft.com/api/mcp" - # 2) Enter the caller-owned MCP Client before creating the MCP tool. - async with Client(server=MS_LEARN_MCP_URL) as mcp_client: - # 3) Enter the MCP wrapper so its background tasks close before the caller-owned Client. - async with MCPStreamableHTTPTool(name="ms_learn", mcp_client=mcp_client) as mcp_tool: - research_agent = Agent( - client=chat_client, - name="research_agent", - tools=mcp_tool, - instructions="You are a Microsoft research agent backing your research with Microsoft Learn docs.", - ) - writer_agent = Agent( - client=chat_client, - name="writer_agent", - instructions=( - "You are a technical writer who explains concepts clearly, concisely, and focuses on the key " - "points." - ), - ) - - # 4) Build and run the workflow while the caller-owned MCP Client and wrapper remain entered. - workflow = SequentialBuilder(participants=[research_agent, writer_agent]).build().as_agent() - response = await workflow.run("Explain how to set up the Python Agent Framework.") - print(response.text) + # 2) Enter the caller-owned MCP Client before creating the MCP tool + async with ( + Client(server=MS_LEARN_MCP_URL) as mcp_client, + MCPStreamableHTTPTool(name="ms_learn", mcp_client=mcp_client) as mcp_tool + ): + + research_agent = Agent( + client=chat_client, + name="research_agent", + tools=mcp_tool, + instructions="You are a Microsoft research agent backing your research with Microsoft Learn docs.", + ) + writer_agent = Agent( + client=chat_client, + name="writer_agent", + instructions=( + "You are a technical writer who explains concepts clearly, concisely, and focuses on the key " + "points." + ), + ) + + # 3) Build and run the workflow while the caller-owned MCP Client and wrapper remain entered. + workflow = SequentialBuilder(participants=[research_agent, writer_agent]).build() + result = await workflow.run("Explain how to set up the Python Agent Framework.") + + for message in result.get_outputs(): + if isinstance(message, AgentResponse): + print(message.text) """ Sample output: From bbad8733c1292fed9ead5fa32443e38fbe981067 Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Thu, 22 Oct 2026 14:22:26 +0200 Subject: [PATCH 7/8] Address workflow sample review feedback --- .../agents/mcp_client_lifecycle.py | 38 +++++++++---------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/python/samples/03-workflows/agents/mcp_client_lifecycle.py b/python/samples/03-workflows/agents/mcp_client_lifecycle.py index 58de234d44b..02e9f24854f 100644 --- a/python/samples/03-workflows/agents/mcp_client_lifecycle.py +++ b/python/samples/03-workflows/agents/mcp_client_lifecycle.py @@ -20,11 +20,10 @@ MCP Client, passes it to `MCPStreamableHTTPTool`, and keeps both open until the sequential workflow finishes. -For most applications, passing the `url=` directly to `MCPStreamableHTTPTool` and let -Agent Framework manage the connection is sufficient. Use this caller-owned Client pattern -when the application must configure or reuse the high-level MCP Client, or keep its -connection alive across multiple workflow runs. The caller owns the Client -lifetime, while Agent Framework still owns the wrapper lifecycle. +For most applications, pass `url=` directly to `MCPStreamableHTTPTool` and let Agent Framework +manage the connection. Use this caller-owned Client pattern when the application must configure +or reuse the high-level MCP Client, or keep its connection alive across multiple workflow runs. +The caller owns the Client lifetime, while Agent Framework still owns the wrapper lifecycle. Prerequisites: - FOUNDRY_PROJECT_ENDPOINT must be set to the Microsoft Foundry project endpoint. @@ -43,12 +42,11 @@ async def main() -> None: MS_LEARN_MCP_URL = "https://learn.microsoft.com/api/mcp" - # 2) Enter the caller-owned MCP Client before creating the MCP tool + # 2) Enter the caller-owned MCP Client before creating the MCP tool. async with ( Client(server=MS_LEARN_MCP_URL) as mcp_client, - MCPStreamableHTTPTool(name="ms_learn", mcp_client=mcp_client) as mcp_tool + MCPStreamableHTTPTool(name="ms_learn", mcp_client=mcp_client) as mcp_tool, ): - research_agent = Agent( client=chat_client, name="research_agent", @@ -59,8 +57,7 @@ async def main() -> None: client=chat_client, name="writer_agent", instructions=( - "You are a technical writer who explains concepts clearly, concisely, and focuses on the key " - "points." + "You are a technical writer who explains concepts clearly, concisely, and focuses on the key points." ), ) @@ -68,19 +65,20 @@ async def main() -> None: workflow = SequentialBuilder(participants=[research_agent, writer_agent]).build() result = await workflow.run("Explain how to set up the Python Agent Framework.") - for message in result.get_outputs(): - if isinstance(message, AgentResponse): - print(message.text) + # Sequential workflows expose the final participant's response by default. + for output in result.get_outputs(): + if isinstance(output, AgentResponse): + print(output.text) - """ - Sample output: + """ + Sample output: - A Microsoft Learn-grounded explanation of how to install Agent Framework, - configure a model provider, authenticate, create an agent, and run it. + A Microsoft Learn-grounded explanation of how to install Agent Framework, + configure a model provider, authenticate, create an agent, and run it. - The exact response varies by model. The workflow-as-agent response contains - the final technical writer output. - """ + The exact response varies by model. The workflow output contains only the + final technical writer response. + """ if __name__ == "__main__": From 24d8f3d6884f63f18501cb3e53f77a57b94de1fc Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Thu, 22 Oct 2026 14:23:39 +0200 Subject: [PATCH 8/8] Update MCP recovery tests for caller-owned clients --- python/packages/core/tests/core/test_mcp.py | 1 - 1 file changed, 1 deletion(-) diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 969c330ee2d..757e2a15f88 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -8548,7 +8548,6 @@ def _caller_owned_mcp_tool(connection_type: str) -> tuple[MCPStdioTool, Any]: return ( MCPStdioTool( name="test", - command="unused", mcp_client=client, load_tools=True, load_prompts=True,