simbiot-harness/venv/lib/python3.12/site-packages/strands/tools/mcp/_compat.py
Simbiot Harness b60439152b Simbiot Harness v0.1: тактовая лестница на Strands + CQRS/ES-спина
- harness/es.py: append-only event store + проекции (образец — кооп-портал)
- harness/levels.py: уровни 1с/1м/1ч/1д, контекст ∝ масштабу
- harness/scheduler.py: уровневые потоки, идемпотентные тики, absorbed-стабы
- harness/isolated_runner.py + _llm_worker.py: LLM-тик в сабпроцессе
  (глобальный asyncio-рантайм Strands не переживает 2+ create_harness)
- cli.py: init/task/tasks/status/events + requeue (написан самим минутным агентом)
- 9router: отдельный ключ sk-simbiot, модель auto (glm-5.3 первым)
2026-09-29 15:28:50 +00:00

727 lines
29 KiB
Python

"""Compatibility layer over the `mcp` 1.x and 2.x lines.
The official `mcp` package renamed and relocated several public names in 2.0.
Import any version-dependent name from this module instead of from `mcp`
directly so the rest of the codebase stays version-agnostic. Branch on
`MCP_V2` only where behavior differs between the two lines; pure renames are
resolved here once.
mypy note: only one `mcp` line is installed at a time, so each try/except
branch below is an `attr-defined` error when checked against the other line.
The branch-level ignores cover whichever line mypy runs under, and the
per-module `warn_unused_ignores` override in pyproject.toml silences the
ignores the installed line doesn't need.
"""
import asyncio
from collections.abc import AsyncGenerator, AsyncIterator, Callable
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
from datetime import timedelta
from typing import Any
import httpx
from mcp import ClientSession
from mcp.types import ServerCapabilities, ToolListChangedNotification
__all__ = [
"MCP_V2",
"GetSessionIdCallback",
"MCPError",
"call_tool",
"client_credentials_auth",
"get_prompt",
"input_schema",
"is_error",
"is_tools_list_changed",
"mime_type",
"negotiate_session",
"next_cursor",
"output_schema",
"read_resource",
"read_timeout",
"resource_templates",
"server_task_capable",
"streamable_http_transport",
"structured_content",
"task_session_kwargs",
"task_support",
"tools_changed_subscription",
]
# Feature-probed rather than version-parsed so pre-releases and backports
# resolve by capability: `ClientSession.discover` is the 2.x replacement for
# the removed initialize handshake.
MCP_V2: bool = hasattr(ClientSession, "discover")
# The official SDKs' shared default for SEP-2322 multi round-trip retries.
_INPUT_REQUIRED_MAX_ROUNDS = 10
_STATE_ONLY_RETRY_DELAY_SECONDS = 0.05
try:
from mcp.shared.exceptions import MCPError # type: ignore[attr-defined]
except ImportError:
# mcp 1.x spells the class McpError
from mcp.shared.exceptions import McpError as MCPError # type: ignore[attr-defined, no-redef]
try:
from mcp.client.streamable_http import GetSessionIdCallback # type: ignore[attr-defined]
except ImportError:
# mcp 2.x removed protocol sessions, so its transports never yield a
# session-id callback; the alias survives only to type 1.x transports.
GetSessionIdCallback = Callable[[], str | None] # type: ignore[misc, assignment]
async def _drive_input_required(session: ClientSession, call_once: Callable[[Any, str | None], Any]) -> Any:
"""Retry a 2.x request until it stops returning `InputRequiredResult`.
The 2026-07-28 spec (SEP-2322) replaced server-initiated requests with multi round-trip requests: instead of
sending `elicitation/create` back to the client mid-call, the server returns an `InputRequiredResult` from
`tools/call`, `prompts/get`, or `resources/read`, and the client resolves the embedded input requests and retries
the call carrying the responses and the echoed opaque `request_state`. Each round resolves the embedded requests
through `session.dispatch_input_request`, the same callback table that serves 1.x server-initiated requests, so an
`elicitation_callback` passed to `ClientSession` behaves the same on both lines.
The `mcp` package ships its own retry loop, but only inside the high-level `mcp.client.Client`, which owns
connection lifecycle that `MCPClient` manages itself, so the loop is reimplemented here at the session level.
Args:
session: An active, negotiated `ClientSession`.
call_once: Sends one request round, taking the input responses and the request state to carry.
Returns:
The terminal result.
Raises:
MCPError: An embedded input request's callback answered with an error.
InputRequiredRoundsExceededError: The server kept returning `InputRequiredResult` past the round cap.
"""
from mcp.client import ClientRequestContext, InputRequiredRoundsExceededError # type: ignore[attr-defined]
from mcp.types import ErrorData, InputRequiredResult # type: ignore[attr-defined]
result = await call_once(None, None)
for _ in range(_INPUT_REQUIRED_MAX_ROUNDS):
if not isinstance(result, InputRequiredResult):
return result
responses: dict[str, Any] = {}
for key, request in (result.input_requests or {}).items():
context = ClientRequestContext(
session=session, request_id=key, meta=request.params.meta if request.params else None
)
response = await session.dispatch_input_request(context, request) # type: ignore[attr-defined]
if isinstance(response, ErrorData):
raise MCPError(code=response.code, message=response.message, data=response.data)
responses[key] = response
if not responses:
# A state-only result asks the client to poll: wait a beat so the retry loop cannot hammer the server.
await asyncio.sleep(_STATE_ONLY_RETRY_DELAY_SECONDS)
result = await call_once(responses or None, result.request_state)
if isinstance(result, InputRequiredResult):
raise InputRequiredRoundsExceededError(_INPUT_REQUIRED_MAX_ROUNDS)
return result
async def call_tool(
session: ClientSession,
name: str,
arguments: dict[str, Any] | None,
read_timeout_seconds: timedelta | None,
progress_callback: Any,
meta: Any,
*,
allow_claimed: bool = False,
) -> Any:
"""Call a tool on an active session, on either `mcp` major line.
On the 2.x branch the server may answer with an `InputRequiredResult` mid-call. `_drive_input_required`
resolves the embedded input requests and retries until the call reaches a terminal result.
Args:
session: An active, negotiated `ClientSession`.
name: The tool name as the server knows it.
arguments: Arguments to pass to the tool.
read_timeout_seconds: Timeout for each request round, if any.
progress_callback: Callback for progress notifications, if any.
meta: Request metadata (`_meta`) to send with the call, if any.
allow_claimed: Return extension-claimed results without resolving them.
Returns:
The terminal `CallToolResult`, or an unresolved extension result when
``allow_claimed`` is enabled.
Raises:
MCPError: An embedded input request's callback answered with an error.
InputRequiredRoundsExceededError: The server kept returning `InputRequiredResult` past the round cap.
"""
if not MCP_V2:
return await session.call_tool(
name,
arguments,
read_timeout_seconds, # type: ignore[arg-type]
progress_callback=progress_callback,
meta=meta,
)
timeout = read_timeout(read_timeout_seconds)
claim_options = {"allow_claimed": True} if allow_claimed else {}
async def call_once(input_responses: Any, request_state: str | None) -> Any:
return await session.call_tool( # type: ignore[call-arg]
name,
arguments,
timeout, # type: ignore[arg-type]
progress_callback=progress_callback,
meta=meta,
input_responses=input_responses,
request_state=request_state,
allow_input_required=True,
**claim_options,
)
return await _drive_input_required(session, call_once)
def client_credentials_auth(
server_url: str, storage: Any, client_id: str, client_secret: str, scope: str | None
) -> httpx.Auth:
"""Construct the OAuth client_credentials provider, on either `mcp` major line.
`mcp` 2.x renamed the provider's `scopes` keyword to `scope`. The import
path and the space-joined scope string value are the same on both lines.
Args:
server_url: The MCP server endpoint URL the grant authenticates against.
storage: The provider's token storage.
client_id: The OAuth client identifier.
client_secret: The OAuth client secret.
scope: Space-joined OAuth scopes to request, if any.
Returns:
The provider, an HTTPX auth implementation.
"""
from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider
if MCP_V2:
return ClientCredentialsOAuthProvider( # type: ignore[return-value]
server_url=server_url,
storage=storage,
client_id=client_id,
client_secret=client_secret,
scope=scope, # type: ignore[call-arg]
)
return ClientCredentialsOAuthProvider( # type: ignore[return-value]
server_url=server_url,
storage=storage,
client_id=client_id,
client_secret=client_secret,
scopes=scope, # type: ignore[call-arg]
)
async def get_prompt(session: ClientSession, name: str, arguments: dict[str, str] | None) -> Any:
"""Get a prompt on an active session, on either `mcp` major line.
On the 2.x branch the server may answer with an `InputRequiredResult` mid-call. `_drive_input_required`
resolves the embedded input requests and retries until the call reaches a terminal result.
Args:
session: An active, negotiated `ClientSession`.
name: The prompt name as the server knows it.
arguments: Arguments to pass to the prompt.
Returns:
The terminal `GetPromptResult`.
Raises:
MCPError: An embedded input request's callback answered with an error.
InputRequiredRoundsExceededError: The server kept returning `InputRequiredResult` past the round cap.
"""
if not MCP_V2:
return await session.get_prompt(name, arguments=arguments)
async def call_once(input_responses: Any, request_state: str | None) -> Any:
return await session.get_prompt( # type: ignore[call-arg]
name,
arguments=arguments,
input_responses=input_responses,
request_state=request_state,
allow_input_required=True,
)
return await _drive_input_required(session, call_once)
async def read_resource(session: ClientSession, uri: Any) -> Any:
"""Read a resource on an active session, on either `mcp` major line.
On the 2.x branch the server may answer with an `InputRequiredResult` mid-call. `_drive_input_required`
resolves the embedded input requests and retries until the call reaches a terminal result.
Args:
session: An active, negotiated `ClientSession`.
uri: The resource URI. 1.x takes the pydantic `AnyUrl` model. 2.x takes a plain string, so anything
`str()` accepts works there.
Returns:
The terminal `ReadResourceResult`.
Raises:
MCPError: An embedded input request's callback answered with an error.
InputRequiredRoundsExceededError: The server kept returning `InputRequiredResult` past the round cap.
"""
if not MCP_V2:
return await session.read_resource(uri)
async def call_once(input_responses: Any, request_state: str | None) -> Any:
return await session.read_resource( # type: ignore[call-arg]
str(uri), # type: ignore[arg-type]
input_responses=input_responses,
request_state=request_state,
allow_input_required=True,
)
return await _drive_input_required(session, call_once)
def input_schema(tool: Any) -> dict[str, Any]:
"""Read a tool's input schema, on either `mcp` major line.
`mcp` 2.x renamed the pydantic model fields to snake_case, so the field
is `input_schema` there and `inputSchema` on 1.x.
Args:
tool: A `Tool` from a list tools result.
Returns:
The tool's JSON input schema.
"""
schema: dict[str, Any] = tool.input_schema if MCP_V2 else tool.inputSchema
return schema
def is_error(call_tool_result: Any) -> bool | None:
"""Read a tool call result's error flag, on either `mcp` major line.
`mcp` 2.x renamed the pydantic result models' camelCase fields to
snake_case, so the field is `is_error` there and `isError` on 1.x.
Args:
call_tool_result: A `CallToolResult` returned by the session.
Returns:
The tool's application-level error flag.
"""
error: bool | None = call_tool_result.is_error if MCP_V2 else call_tool_result.isError
return error
def is_tools_list_changed(message: Any) -> bool:
"""Return whether a message-handler message is a tools list-changed notification.
The 1.x session hands the message handler a `ServerNotification` wrapper
whose `root` holds the notification; the 2.x session hands it the
notification itself, including events it tees from a
`subscriptions/listen` stream on a 2026-07-28 connection. Both shapes are
accepted unconditionally, so no version branch is needed.
Args:
message: A message delivered to the session's `message_handler`.
Returns:
Whether the message announces a change to the server's tool list.
"""
if isinstance(message, ToolListChangedNotification):
return True
return isinstance(getattr(message, "root", None), ToolListChangedNotification)
def mime_type(content: Any) -> str | None:
"""Read a content or resource block's MIME type, on either `mcp` major line.
`mcp` 2.x renamed the pydantic model fields to snake_case, so the field
is `mime_type` there and `mimeType` on 1.x.
Args:
content: An `ImageContent`, `AudioContent`, or `*ResourceContents` model.
Returns:
The block's MIME type, or None when the model leaves it unset.
"""
mime: str | None = content.mime_type if MCP_V2 else content.mimeType
return mime
def next_cursor(list_result: Any) -> str | None:
"""Read a paginated list result's continuation cursor, on either `mcp` major line.
`mcp` 2.x renamed the pydantic result models' camelCase fields to
snake_case, so the field is `next_cursor` there and `nextCursor` on 1.x.
Args:
list_result: A `List*Result` returned by a session `list_*` method.
Returns:
The cursor for the next page, or None on the last page.
"""
cursor: str | None = list_result.next_cursor if MCP_V2 else list_result.nextCursor
return cursor
def output_schema(tool: Any) -> dict[str, Any] | None:
"""Read a tool's output schema, on either `mcp` major line.
`mcp` 2.x renamed the pydantic model fields to snake_case, so the field
is `output_schema` there and `outputSchema` on 1.x.
Args:
tool: A `Tool` from a list tools result.
Returns:
The tool's JSON output schema, or None when the tool declares none.
"""
schema: dict[str, Any] | None = tool.output_schema if MCP_V2 else tool.outputSchema
return schema
def read_timeout(timeout: timedelta | None) -> float | timedelta | None:
"""Convert a tool call read timeout to the form the installed `mcp` line takes.
The session `call_tool` takes `read_timeout_seconds` as a `timedelta` on
1.x and as a `float` of seconds on 2.x.
Args:
timeout: The timeout from the `MCPClient` public API, if any.
Returns:
The value to pass as the session's `read_timeout_seconds`.
"""
if timeout is None:
return None
return timeout.total_seconds() if MCP_V2 else timeout
def resource_templates(list_result: Any) -> list[Any]:
"""Read a list result's resource templates, on either `mcp` major line.
`mcp` 2.x renamed the pydantic result models' camelCase fields to
snake_case, so the field is `resource_templates` there and
`resourceTemplates` on 1.x.
Args:
list_result: A `ListResourceTemplatesResult` returned by the session.
Returns:
The resource templates in this page of the result.
"""
templates: list[Any] = list_result.resource_templates if MCP_V2 else list_result.resourceTemplates
return templates
def structured_content(call_tool_result: Any) -> Any:
"""Read a tool call result's structured content, on either `mcp` major line.
`mcp` 2.x renamed the pydantic result models' camelCase fields to
snake_case, so the field is `structured_content` there and
`structuredContent` on 1.x.
The return is `Any` because the lines type the field differently: 1.x
validates it to `dict[str, Any] | None`, while 2.x allows any JSON value
(the 2026-07-28 spec lifted the JSON-object restriction). Non-dict values
pass through unchanged; callers decide how to surface them.
Args:
call_tool_result: A `CallToolResult` returned by the session.
Returns:
The structured JSON payload, or None when the tool returned none.
"""
return call_tool_result.structured_content if MCP_V2 else call_tool_result.structuredContent
def task_support(tool: Any) -> str | None:
"""Read a tool's declared task execution support level, on either `mcp` major line.
`mcp` 2.x renamed the pydantic model fields to snake_case, so the field
is `execution.task_support` there and `execution.taskSupport` on 1.x.
Args:
tool: A `Tool` from a list tools result.
Returns:
The declared support level, or None when the tool does not declare one.
"""
if tool.execution is None:
return None
support: str | None = tool.execution.task_support if MCP_V2 else tool.execution.taskSupport
return support
def server_task_capable(capabilities: ServerCapabilities | None) -> bool:
"""Check whether a server supports the installed line's task protocol.
MCP 1.x advertises the legacy task capability under ``tasks``. MCP 2.x
advertises finalized SEP-2663 support through the extension registry.
Args:
capabilities: Capabilities negotiated with the server.
Returns:
Whether the server advertised compatible task support.
"""
if capabilities is None:
return False
if MCP_V2:
from .mcp_tasks import _TASKS_EXTENSION
extensions = getattr(capabilities, "extensions", None)
return extensions is not None and _TASKS_EXTENSION in extensions
return (
capabilities.tasks is not None
and capabilities.tasks.requests is not None
and capabilities.tasks.requests.tools is not None
and capabilities.tasks.requests.tools.call is not None
)
def task_session_kwargs(enabled: bool) -> dict[str, Any]:
"""Build MCP 2.x ``ClientSession`` options for finalized Tasks support.
The extension claim teaches the 2.x result codec to parse ``resultType:
task``. Strands consumes the task handle itself, so the claim resolver is
intentionally unreachable on the low-level ``ClientSession`` path.
Args:
enabled: Whether the caller opted into task support.
Returns:
Additional keyword arguments for ``ClientSession``.
"""
if not enabled or not MCP_V2:
return {}
from mcp.client.extension import ResultClaim # type: ignore[import-not-found]
from .mcp_tasks import _TASKS_EXTENSION, _TASKS_PROTOCOL_VERSION, MCPCreateTaskResult
async def unexpected_resolver(result: MCPCreateTaskResult, context: Any) -> Any:
_ = (result, context)
raise RuntimeError("MCP task claims are resolved by MCPClient")
claim = ResultClaim(
result_type="task",
model=MCPCreateTaskResult,
resolve=unexpected_resolver,
protocol_versions=frozenset({_TASKS_PROTOCOL_VERSION}),
)
return {
"extensions": {_TASKS_EXTENSION: {}},
"result_claims": {_TASKS_EXTENSION: (claim,)},
}
async def negotiate_session(session: ClientSession) -> tuple[str | None, ServerCapabilities | None]:
"""Negotiate the connection on an entered session, on either `mcp` major line.
The 2026-07-28 spec (SEP-2575) removed the `initialize` handshake and
moved negotiation into a per-request `_meta` envelope that the 2.x client
stamps itself. `server/discover` is the optional up-front probe for the
server's supported versions and capabilities. `negotiate_auto` is the
official 2.x client's connect-time policy over it: it probes
`server/discover` first and falls back to the legacy handshake for
pre-2026 servers, so either server era works. It is importable from
`mcp.client.client` but not exported in an `__all__`, so the forced-2.x
CI job is what catches a relocation.
Args:
session: An entered `ClientSession` that has not yet negotiated.
Returns:
The server's instructions (if any) and advertised capabilities.
"""
if MCP_V2:
from mcp.client.client import negotiate_auto # type: ignore[import-not-found]
await negotiate_auto(session)
return session.instructions, session.server_capabilities # type: ignore[attr-defined]
init_result = await session.initialize()
return init_result.instructions, session.get_server_capabilities() # type: ignore[attr-defined]
@asynccontextmanager
async def tools_changed_subscription(session: ClientSession) -> AsyncIterator[Any]:
"""Hold open the subscription that makes a 2026-07-28 server publish tools list changes.
The 2026-07-28 spec (SEP-2575) consolidated server notifications into an
opt-in `subscriptions/listen` stream: a modern server emits a tools
list-changed event only while a subscriber holds the stream open. The 2.x
session tees each event to the `message_handler` as a
`ToolListChangedNotification`, so holding the stream open is all the
intake requires. The 1.x line and legacy-negotiated 2.x connections push
the notification without any subscription; those yield None and hold
nothing.
Args:
session: An active, negotiated `ClientSession`.
Yields:
The subscription handle, or None when the connection has no
subscription to hold.
"""
if not MCP_V2:
yield None
return
from mcp.client.subscriptions import ListenNotSupportedError, listen # type: ignore[import-not-found]
async with AsyncExitStack() as subscription_scope:
try:
subscription = await subscription_scope.enter_async_context(listen(session, tools_list_changed=True))
except ListenNotSupportedError:
# The connection negotiated a legacy protocol version, so the
# server pushes list-changed notifications unprompted.
subscription = None
yield subscription
def _wrap_auth_for_httpx2(auth: httpx.Auth | None) -> Any:
"""Translate an `httpx.Auth` handler into the auth protocol mcp 2.x expects.
mcp 2.x is built on `httpx2`, whose client rejects `httpx.Auth` instances at construction. The two Auth protocols
are identical apart from their request and response model classes, so an `httpx.Auth` is driven through an adapter
that converts the models at each step of the flow. Values that are not `httpx.Auth` instances, which includes the
`httpx2.Auth` implementations such as the 2.x OAuth providers, pass through unchanged.
The adapter sends whatever request the flow yields. Header and URL changes to the original request are copied back
onto it, and a step aimed at another URL (httpx's token-refresh pattern) is sent as its own request. Response
bodies handed to the flow are already decoded, so their wire framing headers (`content-encoding`,
`content-length`, `transfer-encoding`) are dropped. The adapter is async-only because every mcp 2.x transport
drives auth through an async client.
"""
# Non-`httpx.Auth` values return before the httpx2 import so this path also
# works where httpx2 is absent (the mcp 1.x install with `MCP_V2` forced on
# in unit tests).
if not isinstance(auth, httpx.Auth):
return auth
import httpx2 # type: ignore[import-not-found]
wrapped = auth
def _loaded_content(request: Any) -> bytes | None:
# A flow may read `request.content` without declaring `requires_request_body`;
# hand it the body whenever httpx2 already has it in memory.
try:
return request.content # type: ignore[no-any-return]
except httpx2.RequestNotRead:
return None
async def _outgoing_request(request: Any, translated_request: httpx.Request, step: httpx.Request) -> Any:
if step is translated_request:
request.headers = httpx2.Headers(step.headers.raw)
if str(step.url) != str(request.url):
request.url = httpx2.URL(str(step.url))
return request
# The flow issued its own request (httpx's token-refresh pattern): send
# that request, never the original one, so its target and body are honored.
# Only the timeout carries over from the original request's extensions;
# the rest (such as `sni_hostname`) is specific to the original host.
timeout_extension = {key: value for key, value in request.extensions.items() if key == "timeout"}
return httpx2.Request(
str(step.method),
str(step.url),
headers=step.headers.raw,
content=await step.aread(),
extensions=timeout_extension,
)
def _translate_response(response: Any, response_body: bytes, step: httpx.Request) -> httpx.Response:
# `response_body` is already decoded, so the wire framing headers no longer
# describe it; keeping `content-encoding` would make httpx decode it again.
headers = [
(name, value)
for name, value in response.headers.raw
if name.lower() not in (b"content-encoding", b"content-length", b"transfer-encoding")
]
return httpx.Response(response.status_code, headers=headers, content=response_body, request=step)
class _HttpxAuthAdapter(httpx2.Auth): # type: ignore[misc]
"""Drives an `httpx.Auth` flow with httpx2 request and response models."""
requires_request_body = wrapped.requires_request_body
requires_response_body = wrapped.requires_response_body
async def async_auth_flow(self, request: Any) -> AsyncGenerator[Any, Any]:
if wrapped.requires_request_body:
await request.aread()
translated_request = httpx.Request(
str(request.method), str(request.url), headers=request.headers.raw, content=_loaded_content(request)
)
flow = wrapped.async_auth_flow(translated_request)
try:
step = await flow.__anext__()
while True:
response = yield await _outgoing_request(request, translated_request, step)
response_body = b""
if wrapped.requires_response_body:
await response.aread()
response_body = response.content
try:
step = await flow.asend(_translate_response(response, response_body, step))
except StopAsyncIteration:
return
finally:
await flow.aclose()
def sync_auth_flow(self, request: Any) -> Any:
raise RuntimeError("this auth adapter only supports async clients; mcp 2.x transports are async")
return _HttpxAuthAdapter()
def streamable_http_transport(
url: str, headers: dict[str, str] | None = None, auth: httpx.Auth | None = None
) -> AbstractAsyncContextManager[Any]:
"""Open a streamable HTTP client transport on either `mcp` major line.
`mcp` 2.x replaced `streamablehttp_client(url, headers=..., auth=...)` with
`streamable_http_client(url, http_client=...)`, which takes a
pre-configured HTTPX client instead of loose header and auth kwargs. This
adapter keeps the 1.x-style call shape for both lines.
The imports are resolved at call time because each name exists on only
one major line.
Args:
url: The MCP server endpoint URL.
headers: Optional HTTP headers to send with each request.
auth: Optional HTTPX authentication handler for each request.
Returns:
An async context manager yielding the transport's read/write streams.
"""
if MCP_V2:
from mcp.client.streamable_http import ( # type: ignore[attr-defined]
create_mcp_http_client,
streamable_http_client,
)
transport_auth = _wrap_auth_for_httpx2(auth)
# `streamable_http_client` closes an HTTPX client only when it created
# it (`client_provided` check in mcp 2.x), so a caller-provided client
# must be closed by the caller: enter both context managers together
# so the client's lifetime is bound to the transport's.
@asynccontextmanager
async def _owned_client_transport() -> AsyncIterator[Any]:
async with (
create_mcp_http_client(headers=headers, auth=transport_auth) as http_client,
streamable_http_client(url=url, http_client=http_client) as transport_streams,
):
yield transport_streams
return _owned_client_transport()
from mcp.client.streamable_http import streamablehttp_client # type: ignore[attr-defined]
return streamablehttp_client(url=url, headers=headers, auth=auth) # type: ignore[no-any-return]