- 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 первым)
727 lines
29 KiB
Python
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]
|