Skip to content
Merged
8 changes: 7 additions & 1 deletion docs/decisions/0045-python-mcp-v2-client-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ 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.

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:

- modern negotiation and legacy initialization fallback;
Expand Down Expand Up @@ -245,4 +251,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.
5 changes: 5 additions & 0 deletions python/packages/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +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.
- **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.
Expand Down
115 changes: 95 additions & 20 deletions python/packages/core/agent_framework/_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -3655,7 +3655,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,
Expand Down Expand Up @@ -3687,13 +3687,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.
Expand All @@ -3714,9 +3719,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.
Expand Down Expand Up @@ -3783,8 +3789,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:
Comment thread
jpalvarezl marked this conversation as resolved.
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,
Expand Down Expand Up @@ -3818,7 +3851,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]:
Expand All @@ -3827,6 +3861,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,
Expand Down Expand Up @@ -3870,7 +3909,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,
Expand Down Expand Up @@ -3903,15 +3942,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.
Expand All @@ -3932,7 +3976,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.
Expand Down Expand Up @@ -4070,11 +4117,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,
Expand Down Expand Up @@ -4130,6 +4198,8 @@ def __init__(

def _mcp_base_span_attributes(self) -> dict[str, Any]:
attrs = super()._mcp_base_span_attributes()
if self.url is None:
return attrs
Comment thread
jpalvarezl marked this conversation as resolved.
attrs[OtelAttr.NETWORK_TRANSPORT] = "tcp"
attrs[OtelAttr.NETWORK_PROTOCOL_NAME] = "http"
try:
Expand Down Expand Up @@ -4158,6 +4228,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()
Expand Down
Loading
Loading