- 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 первым)
2734 lines
125 KiB
Python
2734 lines
125 KiB
Python
"""Model Context Protocol (MCP) server connection management module.
|
|
|
|
This module provides the MCPClient class which handles connections to MCP servers.
|
|
It manages the lifecycle of MCP connections, including initialization, tool discovery,
|
|
tool invocation, and proper cleanup of resources. The connection runs in a background
|
|
thread to avoid blocking the main application thread while maintaining communication
|
|
with the MCP service.
|
|
"""
|
|
|
|
import asyncio
|
|
import base64
|
|
import contextvars
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import sys
|
|
import threading
|
|
import uuid
|
|
from asyncio import AbstractEventLoop
|
|
from collections.abc import Callable, Coroutine, Sequence
|
|
from concurrent import futures
|
|
from contextlib import AsyncExitStack
|
|
from datetime import timedelta
|
|
from importlib.metadata import version as pkg_version
|
|
from pathlib import Path
|
|
from re import Pattern
|
|
from types import TracebackType
|
|
from typing import Any, TypeVar, cast
|
|
from urllib.parse import urlparse
|
|
|
|
import anyio
|
|
import httpx
|
|
from mcp import ClientSession, ListToolsResult, StdioServerParameters, stdio_client
|
|
from mcp.client.session import ElicitationFnT, ProgressFnT
|
|
from mcp.client.sse import sse_client
|
|
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
|
from mcp.types import (
|
|
BlobResourceContents,
|
|
CancelledNotification,
|
|
CancelledNotificationParams,
|
|
ClientNotification,
|
|
ElicitationRequiredErrorData,
|
|
GetPromptResult,
|
|
Implementation,
|
|
ListPromptsResult,
|
|
ListResourcesResult,
|
|
ListResourceTemplatesResult,
|
|
PaginatedRequestParams,
|
|
ReadResourceResult,
|
|
TextResourceContents,
|
|
)
|
|
from mcp.types import CallToolResult as MCPCallToolResult
|
|
from mcp.types import EmbeddedResource as MCPEmbeddedResource
|
|
from mcp.types import ImageContent as MCPImageContent
|
|
from mcp.types import TextContent as MCPTextContent
|
|
from pydantic import AnyUrl
|
|
from typing_extensions import Protocol, TypedDict
|
|
|
|
from ...types import PaginatedList
|
|
from ...types.exceptions import MCPClientInitializationError, ToolProviderException
|
|
from ...types.media import ImageFormat
|
|
from ...types.tools import AgentTool, ToolResultContent, ToolResultStatus
|
|
from ..tool_provider import ToolProvider
|
|
from ._compat import (
|
|
MCP_V2,
|
|
MCPError,
|
|
client_credentials_auth,
|
|
is_error,
|
|
is_tools_list_changed,
|
|
mime_type,
|
|
negotiate_session,
|
|
next_cursor,
|
|
read_timeout,
|
|
resource_templates,
|
|
server_task_capable,
|
|
streamable_http_transport,
|
|
structured_content,
|
|
task_session_kwargs,
|
|
task_support,
|
|
tools_changed_subscription,
|
|
)
|
|
from ._compat import call_tool as compat_call_tool
|
|
from ._compat import get_prompt as compat_get_prompt
|
|
from ._compat import read_resource as compat_read_resource
|
|
from .mcp_agent_tool import MCPAgentTool
|
|
from .mcp_instrumentation import inject_trace_context, mcp_instrumentation
|
|
from .mcp_tasks import (
|
|
_TASKS_EXTENSION,
|
|
_TASKS_PROTOCOL_VERSION,
|
|
DEFAULT_TASK_CONFIG,
|
|
DEFAULT_TASK_POLL_INTERVAL,
|
|
DEFAULT_TASK_POLL_TIMEOUT,
|
|
DEFAULT_TASK_REQUEST_TIMEOUT,
|
|
DEFAULT_TASK_TTL,
|
|
MCPCancelTaskResult,
|
|
MCPCreateTaskResult,
|
|
MCPGetTaskResult,
|
|
MCPInputRequest,
|
|
MCPInputResponses,
|
|
MCPTaskError,
|
|
MCPUpdateTaskResult,
|
|
TasksConfig,
|
|
_CancelTaskRequest,
|
|
_CancelTaskRequestParams,
|
|
_GetTaskRequest,
|
|
_GetTaskRequestParams,
|
|
_parse_task_timestamp,
|
|
_UpdateTaskRequest,
|
|
_UpdateTaskRequestParams,
|
|
)
|
|
from .mcp_types import MCPClientCredentials, MCPToolResult, MCPTransport, ToolsChanged
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
T = TypeVar("T")
|
|
|
|
|
|
def _pagination_params(pagination_token: str | None) -> PaginatedRequestParams | None:
|
|
"""Build the params object for a paginated list_* request.
|
|
|
|
The session list methods' `cursor` keyword is deprecated on the mcp 1.x
|
|
line and removed on 2.x; `params` is the form both lines accept.
|
|
"""
|
|
return PaginatedRequestParams(cursor=pagination_token) if pagination_token is not None else None
|
|
|
|
|
|
class _MCPCallCancelledError(RuntimeError):
|
|
"""Raised internally when a per-call MCP cancellation signal is observed."""
|
|
|
|
|
|
class _ToolFilterCallback(Protocol):
|
|
def __call__(self, tool: AgentTool, **kwargs: Any) -> bool: ...
|
|
|
|
|
|
_ToolMatcher = str | Pattern[str] | _ToolFilterCallback
|
|
|
|
|
|
class ToolFilters(TypedDict, total=False):
|
|
"""Filters for controlling which MCP tools are loaded and available.
|
|
|
|
Tools are filtered in this order:
|
|
1. If 'allowed' is specified, only tools matching these patterns are included
|
|
2. Tools matching 'rejected' patterns are then excluded
|
|
"""
|
|
|
|
allowed: list[_ToolMatcher]
|
|
rejected: list[_ToolMatcher]
|
|
|
|
|
|
class _CallCancellationState(TypedDict, total=False):
|
|
session: ClientSession
|
|
request_id: int
|
|
task_id: str
|
|
notification_sent: bool
|
|
|
|
|
|
class MCPServerConfig(TypedDict, total=False):
|
|
"""Schema for a single MCP server entry in a load_servers config.
|
|
|
|
Provide either 'command' (stdio) or 'url' (streamable-http/sse), not both. When 'transport' is
|
|
omitted it is auto-detected from the fields present. String values support '${VAR}' /
|
|
'${env:VAR}' interpolation, and '~' in 'command' and 'cwd' is expanded to the home directory.
|
|
|
|
'auth' holds client credentials for OAuth machine-to-machine (client_credentials grant)
|
|
authentication and is only supported for streamable-http transport.
|
|
|
|
'disabled' skips the server entirely. 'continue_on_error' keeps the rest of the servers usable
|
|
when this one fails: a config-resolution failure (e.g. a missing env var) skips it during
|
|
load_servers instead of raising, and a connection failure yields no tools instead of raising
|
|
when the agent loads them.
|
|
"""
|
|
|
|
command: str
|
|
args: list[str]
|
|
env: dict[str, str]
|
|
cwd: str
|
|
url: str
|
|
headers: dict[str, str]
|
|
transport: str
|
|
auth: MCPClientCredentials
|
|
disabled: bool
|
|
continue_on_error: bool
|
|
prefix: str
|
|
tool_filters: ToolFilters
|
|
startup_timeout: int
|
|
application_name: str
|
|
application_version: str
|
|
|
|
|
|
MIME_TO_FORMAT: dict[str, ImageFormat] = {
|
|
"image/jpeg": "jpeg",
|
|
"image/jpg": "jpeg",
|
|
"image/png": "png",
|
|
"image/gif": "gif",
|
|
"image/webp": "webp",
|
|
}
|
|
|
|
CLIENT_SESSION_NOT_RUNNING_ERROR_MESSAGE = (
|
|
"the client session is not running. Ensure the agent is used within "
|
|
"the MCP client context manager. For more information see: "
|
|
"https://strandsagents.com/docs/user-guide/concepts/tools/mcp-tools/#mcpclientinitializationerror-python"
|
|
)
|
|
|
|
# Non-fatal error patterns that should not cause connection collapse
|
|
_NON_FATAL_ERROR_PATTERNS = [
|
|
# Occurs when client receives response with unrecognized ID
|
|
# Can occur after a client-side timeout
|
|
# See: https://github.com/modelcontextprotocol/python-sdk/blob/c51936f61f35a15f0b1f8fb6887963e5baee1506/src/mcp/shared/session.py#L421
|
|
"unknown request id",
|
|
]
|
|
|
|
_TOOLS_CHANGED_DEBOUNCE_SECONDS = 0.3
|
|
|
|
|
|
class MCPClient(ToolProvider):
|
|
"""Represents a connection to a Model Context Protocol (MCP) server.
|
|
|
|
This class implements a context manager pattern for efficient connection management,
|
|
allowing reuse of the same connection for multiple tool calls to reduce latency.
|
|
It handles the creation, initialization, and cleanup of MCP connections.
|
|
|
|
The connection runs in a background thread to avoid blocking the main application thread
|
|
while maintaining communication with the MCP service. When structured content is available
|
|
from MCP tools, it will be returned as the last item in the content array of the ToolResult.
|
|
"""
|
|
|
|
@classmethod
|
|
def load_servers(
|
|
cls,
|
|
config: "str | dict[str, Any]",
|
|
*,
|
|
continue_on_error: bool = False,
|
|
prefix_with_server_name: bool = False,
|
|
) -> "list[MCPClient]":
|
|
"""Create MCPClient instances from an ``mcpServers`` JSON config (file path or mapping).
|
|
|
|
Returns one client per enabled server. Accepts either a flat mapping of server name to
|
|
config, or that mapping nested under an ``mcpServers`` key. Servers marked
|
|
``"disabled": true`` are skipped. When a server has ``"continue_on_error": true`` (or the
|
|
``continue_on_error`` default is set), a failure resolving its config (e.g. a missing env
|
|
var) skips that server instead of raising.
|
|
|
|
Transport is auto-detected from the fields present: ``command`` selects stdio and ``url``
|
|
selects streamable-http. Set ``transport`` explicitly (``"stdio"``, ``"sse"``, or
|
|
``"streamable-http"``) to override. String values support ``${VAR}`` / ``${env:VAR}``
|
|
interpolation against the process environment, and ``~`` in ``command`` and ``cwd`` is
|
|
expanded to the user's home directory.
|
|
|
|
Args:
|
|
config: A file path (with optional ``file://`` prefix) to a JSON config, or a
|
|
dictionary mapping server names to configs (optionally under an ``mcpServers`` key).
|
|
continue_on_error: Default ``continue_on_error`` for every server; a server's own
|
|
``continue_on_error`` key overrides it.
|
|
prefix_with_server_name: When True, servers without an explicit ``prefix`` use their config
|
|
key as the tool name prefix, so same-named tools from different servers no longer
|
|
collide. Characters outside ``[A-Za-z0-9_-]`` in the key (e.g. the dot in
|
|
``awslabs.foo``) are replaced with ``_``. A server can still opt out with ``"prefix": ""``.
|
|
|
|
Returns:
|
|
One MCPClient per enabled server, ready to pass to ``Agent(tools=...)``.
|
|
|
|
Raises:
|
|
FileNotFoundError: If the config file does not exist.
|
|
json.JSONDecodeError: If the config file contains invalid JSON.
|
|
ValueError: If the overall config shape is invalid or a server entry is not a mapping.
|
|
These are malformed-config errors and always raise, regardless of
|
|
``continue_on_error``. A failure building an individual server (e.g. a missing env
|
|
var) also raises unless ``continue_on_error`` applies to that server, in which case
|
|
it is skipped.
|
|
"""
|
|
servers = _load_servers_mapping(config)
|
|
|
|
clients: list[MCPClient] = []
|
|
for name, server in servers.items():
|
|
# A non-dict entry is a malformed-config error and always raises, unlike per-server failures.
|
|
if not isinstance(server, dict):
|
|
raise ValueError(f"server '{name}' configuration must be a dictionary, got {type(server).__name__}")
|
|
unknown_keys = sorted(server.keys() - MCPServerConfig.__annotations__.keys())
|
|
if unknown_keys:
|
|
logger.warning(
|
|
"server_name=<%s>, unknown_keys=<%s> | ignoring unrecognized MCP config keys", name, unknown_keys
|
|
)
|
|
if server.get("disabled", False):
|
|
logger.debug("server_name=<%s> | skipping disabled MCP server", name)
|
|
continue
|
|
try:
|
|
clients.append(
|
|
_build_client_from_config(
|
|
name,
|
|
server,
|
|
continue_on_error=continue_on_error,
|
|
prefix_with_server_name=prefix_with_server_name,
|
|
)
|
|
)
|
|
except Exception as e:
|
|
if not server.get("continue_on_error", continue_on_error):
|
|
raise
|
|
logger.warning("server_name=<%s>, error=<%s> | MCP server config failed, skipping", name, e)
|
|
|
|
logger.debug("loaded_servers=<%d> | created MCP clients from config", len(clients))
|
|
return clients
|
|
|
|
def __init__(
|
|
self,
|
|
transport_callable: Callable[[], MCPTransport] | None = None,
|
|
*,
|
|
url: str | None = None,
|
|
headers: dict[str, str] | None = None,
|
|
auth: MCPClientCredentials | None = None,
|
|
auth_provider: httpx.Auth | None = None,
|
|
startup_timeout: int = 30,
|
|
tool_filters: ToolFilters | None = None,
|
|
prefix: str | None = None,
|
|
application_name: str | None = None,
|
|
application_version: str | None = None,
|
|
continue_on_error: bool = False,
|
|
elicitation_callback: ElicitationFnT | None = None,
|
|
progress_callback: ProgressFnT | None = None,
|
|
tasks_config: TasksConfig | None = None,
|
|
on_tools_changed: ToolsChanged | None = None,
|
|
) -> None:
|
|
"""Initialize a new MCP Server connection.
|
|
|
|
Args:
|
|
transport_callable: A callable that returns an MCPTransport (read_stream, write_stream) tuple.
|
|
Mutually exclusive with `url`.
|
|
url: Server URL. When provided, a streamable HTTP transport is constructed automatically.
|
|
Mutually exclusive with `transport_callable`.
|
|
headers: HTTP headers to include on every request to the server. Requires `url`.
|
|
auth: Client credentials for OAuth machine-to-machine (client_credentials grant)
|
|
authentication. Requires `url`. Mutually exclusive with `auth_provider`.
|
|
auth_provider: Custom `httpx.Auth` for advanced auth flows, passed through to the
|
|
streamable HTTP transport. Requires `url`. Mutually exclusive with `auth`.
|
|
startup_timeout: Timeout after which MCP server initialization should be cancelled.
|
|
Defaults to 30.
|
|
tool_filters: Optional filters to apply to tools.
|
|
prefix: Optional prefix for tool names.
|
|
application_name: Optional name to identify this agent via clientInfo.name.
|
|
If provided, the MCP server will see this name during the initialize handshake.
|
|
Defaults to None (uses the MCP SDK default "mcp").
|
|
application_version: Optional version string to report alongside application_name.
|
|
Defaults to None (uses the Strands SDK version).
|
|
continue_on_error: When True, a connection failure during `load_tools` is logged and
|
|
yields no tools instead of raising, so one unavailable server does not prevent an
|
|
agent from using the others. Only the connection (`start()`) is swallowed; an error
|
|
while listing tools after a successful connect still propagates. Defaults to False.
|
|
elicitation_callback: Optional callback function to handle elicitation requests from the MCP server.
|
|
progress_callback: Optional callback to receive progress notifications during tool execution.
|
|
Called with `(progress, total, message)` as the server reports progress. The `total`
|
|
and `message` parameters may be `None` if the server does not provide them.
|
|
tasks_config: Configuration for MCP task-augmented execution for long-running tools.
|
|
Experimental and subject to change as MCP Tasks evolve. On MCP 2.x, this enables
|
|
finalized SEP-2663 Tasks support. On MCP 1.x, it enables the legacy task
|
|
workflow. See TasksConfig for details.
|
|
on_tools_changed: Optional callback invoked after the server announces a change to its
|
|
tool list and the client refreshes it. Called with the previous tool names and the
|
|
refreshed tool instances. Registering it turns on the refresh: the client listens
|
|
for the server's tools list-changed notifications, re-lists the tools (applying
|
|
the constructor's prefix and filters), and updates the cached tools that
|
|
`load_tools` returns. On a connection that negotiated MCP 2026-07-28, registering
|
|
it also makes `start()` failable: the client must open the server's
|
|
`subscriptions/listen` stream to receive the notifications, and a failure to open
|
|
it (other than the server lacking support) raises `MCPClientInitializationError`
|
|
from `start()`.
|
|
|
|
Raises:
|
|
ValueError: If neither or both of `transport_callable` and `url` are provided, if
|
|
`headers`, `auth`, or `auth_provider` is provided without `url`, if both `auth`
|
|
and `auth_provider` are provided, if `url` is not an http:// or https:// URL, or
|
|
if `auth` is missing required keys or a value has the wrong type.
|
|
"""
|
|
self._startup_timeout = startup_timeout
|
|
self._tool_filters = tool_filters
|
|
self._prefix = prefix
|
|
self._application_name = application_name
|
|
self._application_version = application_version
|
|
self._continue_on_error = continue_on_error
|
|
# True after a swallowed init failure, so load_tools stops retrying a failed server.
|
|
self._connection_failed = False
|
|
self._elicitation_callback = elicitation_callback
|
|
self._progress_callback = progress_callback
|
|
self._on_tools_changed = on_tools_changed
|
|
self._tools_refresh_in_progress = False
|
|
self._tools_refresh_pending = False
|
|
# Keep refresh tasks alive until they finish; asyncio only retains weak references.
|
|
self._tools_refresh_tasks: set[asyncio.Task[None]] = set()
|
|
|
|
mcp_instrumentation()
|
|
self._session_id = uuid.uuid4()
|
|
self._log_debug_with_thread("initializing MCPClient connection")
|
|
# Main thread blocks until future completes
|
|
self._init_future: futures.Future[None] = futures.Future()
|
|
# Set within the inner loop as it needs the asyncio loop
|
|
self._close_future: asyncio.futures.Future[None] | None = None
|
|
self._close_exception: None | Exception = None
|
|
# Do not want to block other threads while close event is false
|
|
self._transport_callable = _resolve_transport_callable(
|
|
transport_callable, url=url, headers=headers, auth=auth, auth_provider=auth_provider
|
|
)
|
|
|
|
self._background_thread: threading.Thread | None = None
|
|
self._background_thread_session: ClientSession | None = None
|
|
self._background_thread_event_loop: AbstractEventLoop | None = None
|
|
self._loaded_tools: list[MCPAgentTool] | None = None
|
|
self._tool_provider_started = False
|
|
self.server_instructions: str | None = None
|
|
self._consumers: set[Any] = set()
|
|
# Keep detached cleanup tasks alive until they finish; asyncio only retains weak references.
|
|
self._background_cleanup_tasks: set[asyncio.Task[Any]] = set()
|
|
self._accept_background_cleanup_tasks = True
|
|
|
|
# Task support configuration and caching
|
|
self._tasks_config = tasks_config
|
|
self._server_task_capable: bool | None = None
|
|
|
|
self._tool_task_support_cache: dict[str, str] = {}
|
|
|
|
def __enter__(self) -> "MCPClient":
|
|
"""Context manager entry point which initializes the MCP server connection.
|
|
|
|
TODO: Refactor to lazy initialization pattern following idiomatic Python.
|
|
Heavy work in __enter__ is non-idiomatic - should move connection logic to first method call instead.
|
|
"""
|
|
return self.start()
|
|
|
|
def __exit__(
|
|
self,
|
|
exc_type: type[BaseException] | None,
|
|
exc_val: BaseException | None,
|
|
exc_tb: TracebackType | None,
|
|
) -> None:
|
|
"""Context manager exit point that cleans up resources."""
|
|
self.stop(exc_type, exc_val, exc_tb)
|
|
|
|
def start(self) -> "MCPClient":
|
|
"""Starts the background thread and waits for initialization.
|
|
|
|
This method starts the background thread that manages the MCP connection
|
|
and blocks until the connection is ready or times out.
|
|
|
|
Returns:
|
|
self: The MCPClient instance
|
|
|
|
Raises:
|
|
Exception: If the MCP connection fails to initialize within the timeout period
|
|
"""
|
|
if self._is_session_active():
|
|
raise MCPClientInitializationError("the client session is currently running")
|
|
|
|
self._log_debug_with_thread("entering MCPClient context")
|
|
self._accept_background_cleanup_tasks = True
|
|
# Copy context vars to propagate to the background thread
|
|
# This ensures that context set in the main thread is accessible in the background thread
|
|
# See: https://github.com/strands-agents/harness-sdk/issues/1440
|
|
ctx = contextvars.copy_context()
|
|
self._background_thread = threading.Thread(target=ctx.run, args=(self._background_task,), daemon=True)
|
|
self._background_thread.start()
|
|
self._log_debug_with_thread("background thread started, waiting for ready event")
|
|
try:
|
|
# Blocking main thread until session is initialized in other thread or if the thread stops
|
|
self._init_future.result(timeout=self._startup_timeout)
|
|
self._log_debug_with_thread("the client initialization was successful")
|
|
except futures.TimeoutError as e:
|
|
logger.exception("client initialization timed out")
|
|
# Pass None for exc_type, exc_val, exc_tb since this isn't a context manager exit
|
|
self.stop(None, None, None)
|
|
raise MCPClientInitializationError(
|
|
f"background thread did not start in {self._startup_timeout} seconds"
|
|
) from e
|
|
except Exception as e:
|
|
logger.exception("client failed to initialize")
|
|
# Pass None for exc_type, exc_val, exc_tb since this isn't a context manager exit
|
|
self.stop(None, None, None)
|
|
raise MCPClientInitializationError(f"the client initialization failed: {e}") from e
|
|
return self
|
|
|
|
@property
|
|
def client_name(self) -> str | None:
|
|
"""The ``application_name`` reported to the server, or ``None`` when unset (see ``__init__``).
|
|
|
|
Defaults to the config key for ``load_servers`` clients.
|
|
"""
|
|
return self._application_name
|
|
|
|
@property
|
|
def continue_on_error(self) -> bool:
|
|
"""Whether a connection failure is swallowed instead of raised (see ``__init__``)."""
|
|
return self._continue_on_error
|
|
|
|
@property
|
|
def connection_failed(self) -> bool:
|
|
"""Whether a ``continue_on_error`` connection attempt has failed and not yet been reset.
|
|
|
|
Sticky within a connection lifecycle: stays True until teardown (removing the last consumer,
|
|
or ``stop()``) resets the client. Always False when ``continue_on_error`` is not set, since a
|
|
failure raises instead.
|
|
"""
|
|
return self._connection_failed
|
|
|
|
@property
|
|
def on_tools_changed(self) -> ToolsChanged | None:
|
|
"""The registered tools-changed callback, if any (see `__init__`)."""
|
|
return self._on_tools_changed
|
|
|
|
@on_tools_changed.setter
|
|
def on_tools_changed(self, callback: ToolsChanged | None) -> None:
|
|
"""Register or remove the tools-changed callback.
|
|
|
|
On a connection that negotiated MCP 2026-07-28, the `subscriptions/listen`
|
|
stream that makes the server publish list-changed notifications is opened
|
|
at session start only when a callback is registered, so a callback set
|
|
after `start()` receives nothing from such a server until the client is
|
|
restarted. Servers on earlier protocol versions push the notification
|
|
unprompted, so a late-set callback works there immediately.
|
|
"""
|
|
self._on_tools_changed = callback
|
|
|
|
# ToolProvider interface methods
|
|
async def load_tools(self, **kwargs: Any) -> Sequence[AgentTool]:
|
|
"""Load and return tools from the MCP server.
|
|
|
|
This method implements the ToolProvider interface by loading tools
|
|
from the MCP server and caching them for reuse.
|
|
|
|
Args:
|
|
**kwargs: Additional arguments for future compatibility.
|
|
|
|
Returns:
|
|
List of AgentTool instances from the MCP server. Empty when the connection fails and
|
|
``continue_on_error`` is set; the failure is sticky within a connection lifecycle and is
|
|
not retried on subsequent calls. Teardown (removing the last consumer, or ``stop()``)
|
|
resets the client so a later consumer reconnects.
|
|
"""
|
|
logger.debug(
|
|
"started=<%s>, cached_tools=<%s> | loading tools",
|
|
self._tool_provider_started,
|
|
self._loaded_tools is not None,
|
|
)
|
|
|
|
# A previously swallowed init failure is not retried on subsequent calls.
|
|
if self._connection_failed:
|
|
return []
|
|
|
|
if not self._tool_provider_started:
|
|
try:
|
|
logger.debug("starting MCP client")
|
|
self.start()
|
|
self._tool_provider_started = True
|
|
logger.debug("MCP client started successfully")
|
|
except Exception as e:
|
|
if self._continue_on_error:
|
|
logger.warning("error=<%s> | MCP server failed to start, continuing with no tools", e)
|
|
self._connection_failed = True
|
|
return []
|
|
logger.error("error=<%s> | failed to start MCP client", e)
|
|
raise ToolProviderException(f"Failed to start MCP client: {e}") from e
|
|
|
|
if self._loaded_tools is None:
|
|
logger.debug("loading tools from MCP server")
|
|
initial_tools = self._list_all_tools_sync()
|
|
# A refresh may have replaced the cache while this listing ran;
|
|
# the refreshed list is newer, so it wins over the initial load.
|
|
if self._loaded_tools is None:
|
|
self._loaded_tools = initial_tools
|
|
logger.debug("final_tools=<%d> | loading complete", len(self._loaded_tools))
|
|
|
|
return self._loaded_tools
|
|
|
|
def _list_all_tools_sync(self) -> list[MCPAgentTool]:
|
|
"""List every tool page with the constructor's prefix and filters applied."""
|
|
all_tools: list[MCPAgentTool] = []
|
|
pagination_token = None
|
|
page_count = 0
|
|
|
|
while True:
|
|
logger.debug("page=<%d>, token=<%s> | fetching tools page", page_count, pagination_token)
|
|
paginated_tools = self.list_tools_sync(
|
|
pagination_token, prefix=self._prefix, tool_filters=self._tool_filters
|
|
)
|
|
|
|
# Tools are already filtered by list_tools_sync, so add them all
|
|
all_tools.extend(paginated_tools)
|
|
|
|
logger.debug(
|
|
"page=<%d>, page_tools=<%d>, total_filtered=<%d> | processed page",
|
|
page_count,
|
|
len(paginated_tools),
|
|
len(all_tools),
|
|
)
|
|
|
|
pagination_token = paginated_tools.pagination_token
|
|
page_count += 1
|
|
|
|
if pagination_token is None:
|
|
return all_tools
|
|
|
|
def add_consumer(self, consumer_id: Any, **kwargs: Any) -> None:
|
|
"""Add a consumer to this tool provider.
|
|
|
|
Synchronous to prevent GC deadlocks when called from Agent finalizers.
|
|
"""
|
|
self._consumers.add(consumer_id)
|
|
logger.debug("added provider consumer, count=%d", len(self._consumers))
|
|
|
|
def remove_consumer(self, consumer_id: Any, **kwargs: Any) -> None:
|
|
"""Remove a consumer from this tool provider.
|
|
|
|
This method is idempotent - calling it multiple times with the same ID
|
|
has no additional effect after the first call.
|
|
|
|
Synchronous to prevent GC deadlocks when called from Agent finalizers.
|
|
Uses existing synchronous stop() method for safe cleanup.
|
|
"""
|
|
self._consumers.discard(consumer_id)
|
|
logger.debug("removed provider consumer, count=%d", len(self._consumers))
|
|
|
|
# A swallowed continue_on_error failure leaves _tool_provider_started False but still needs
|
|
# teardown so the sticky _connection_failed flag resets and the client can reconnect for a
|
|
# later consumer, matching the reset-on-zero-consumers behavior of a successful client.
|
|
if not self._consumers and (self._tool_provider_started or self._connection_failed):
|
|
logger.debug("no consumers remaining, cleaning up")
|
|
try:
|
|
self.stop(None, None, None) # Existing sync method - safe for finalizers
|
|
self._tool_provider_started = False
|
|
self._loaded_tools = None
|
|
except Exception as e:
|
|
logger.error("error=<%s> | failed to cleanup MCP client", e)
|
|
raise ToolProviderException(f"Failed to cleanup MCP client: {e}") from e
|
|
|
|
# MCP-specific methods
|
|
|
|
def stop(
|
|
self,
|
|
exc_type: type[BaseException] | None,
|
|
exc_val: BaseException | None,
|
|
exc_tb: TracebackType | None,
|
|
) -> None:
|
|
"""Signals the background thread to stop and waits for it to complete, ensuring proper cleanup of all resources.
|
|
|
|
This method is defensive and can handle partial initialization states that may occur
|
|
if start() fails partway through initialization.
|
|
|
|
Resources to cleanup:
|
|
- _background_thread: Thread running the async event loop
|
|
- _background_thread_session: MCP ClientSession (auto-closed by context manager)
|
|
- _background_thread_event_loop: AsyncIO event loop in background thread
|
|
- _close_future: AsyncIO future to signal thread shutdown
|
|
- _close_exception: Exception that caused the background thread shutdown; None if a normal shutdown occurred.
|
|
- _init_future: Future for initialization synchronization
|
|
|
|
Cleanup order:
|
|
1. Signal close future to background thread (if session initialized)
|
|
2. Wait for background thread to complete
|
|
3. Reset all state for reuse
|
|
|
|
Args:
|
|
exc_type: Exception type if an exception was raised in the context
|
|
exc_val: Exception value if an exception was raised in the context
|
|
exc_tb: Exception traceback if an exception was raised in the context
|
|
"""
|
|
self._log_debug_with_thread("exiting MCPClient context")
|
|
|
|
# Skip cleanup during interpreter finalization. On Python 3.14+, joining a
|
|
# non-daemon thread at shutdown raises PythonFinalizationError; even though
|
|
# our background thread is a daemon and will be reclaimed automatically,
|
|
# the join call itself produces noisy tracebacks on stderr when the GC
|
|
# reaches Agent.__del__ during finalization. See issue #2143.
|
|
if sys.is_finalizing():
|
|
self._log_debug_with_thread("interpreter is finalizing, skipping MCPClient cleanup")
|
|
return
|
|
|
|
# Only try to signal close future if we have a background thread
|
|
if self._background_thread is not None:
|
|
# Signal close future if event loop exists
|
|
if self._background_thread_event_loop is not None:
|
|
|
|
async def _set_close_event() -> None:
|
|
if self._close_future and not self._close_future.done():
|
|
self._close_future.set_result(None)
|
|
|
|
# Not calling _invoke_on_background_thread since the session does not need to exist
|
|
# we only need the thread and event loop to exist.
|
|
asyncio.run_coroutine_threadsafe(coro=_set_close_event(), loop=self._background_thread_event_loop)
|
|
|
|
self._log_debug_with_thread("waiting for background thread to join")
|
|
self._background_thread.join()
|
|
|
|
if self._background_thread_event_loop is not None:
|
|
self._background_thread_event_loop.close()
|
|
|
|
self._log_debug_with_thread("background thread is closed, MCPClient context exited")
|
|
|
|
# Reset fields to allow instance reuse
|
|
self._init_future = futures.Future()
|
|
self._background_thread = None
|
|
self._background_thread_session = None
|
|
self._background_thread_event_loop = None
|
|
self._session_id = uuid.uuid4()
|
|
self._loaded_tools = None
|
|
self._tool_provider_started = False
|
|
self._connection_failed = False
|
|
self._consumers = set()
|
|
self._background_cleanup_tasks.clear()
|
|
self._server_task_capable = None
|
|
self._tool_task_support_cache = {}
|
|
# A refresh task destroyed with the loop never ran its finally, so
|
|
# without this reset a restarted client would skip every refresh.
|
|
self._tools_refresh_in_progress = False
|
|
self._tools_refresh_pending = False
|
|
self._tools_refresh_tasks.clear()
|
|
|
|
if self._close_exception:
|
|
exception = self._close_exception
|
|
self._close_exception = None
|
|
raise RuntimeError("Connection to the MCP server was closed") from exception
|
|
|
|
def list_tools_sync(
|
|
self,
|
|
pagination_token: str | None = None,
|
|
prefix: str | None = None,
|
|
tool_filters: ToolFilters | None = None,
|
|
) -> PaginatedList[MCPAgentTool]:
|
|
"""Synchronously retrieves the list of available tools from the MCP server.
|
|
|
|
This method calls the asynchronous list_tools method on the MCP session
|
|
and adapts the returned tools to the AgentTool interface.
|
|
|
|
Args:
|
|
pagination_token: Optional token for pagination
|
|
prefix: Optional prefix to apply to tool names. If None, uses constructor default.
|
|
If explicitly provided (including empty string), overrides constructor default.
|
|
tool_filters: Optional filters to apply to tools. If None, uses constructor default.
|
|
If explicitly provided (including empty dict), overrides constructor default.
|
|
|
|
Returns:
|
|
List[AgentTool]: A list of available tools adapted to the AgentTool interface
|
|
"""
|
|
self._log_debug_with_thread("listing MCP tools synchronously")
|
|
if not self._is_session_active():
|
|
raise MCPClientInitializationError(CLIENT_SESSION_NOT_RUNNING_ERROR_MESSAGE)
|
|
|
|
effective_prefix = self._prefix if prefix is None else prefix
|
|
effective_filters = self._tool_filters if tool_filters is None else tool_filters
|
|
|
|
async def _list_tools_async() -> ListToolsResult:
|
|
return await cast(ClientSession, self._background_thread_session).list_tools(
|
|
params=_pagination_params(pagination_token)
|
|
)
|
|
|
|
list_tools_response: ListToolsResult = self._invoke_on_background_thread(_list_tools_async()).result()
|
|
self._log_debug_with_thread("received %d tools from MCP server", len(list_tools_response.tools))
|
|
|
|
mcp_tools = []
|
|
for tool in list_tools_response.tools:
|
|
if self._is_tasks_enabled():
|
|
# Cache taskSupport for task-augmented execution decisions
|
|
self._tool_task_support_cache[tool.name] = task_support(tool) or "forbidden"
|
|
|
|
# Apply prefix if specified
|
|
if effective_prefix:
|
|
prefixed_name = f"{effective_prefix}_{tool.name}"
|
|
mcp_tool = MCPAgentTool(tool, self, name_override=prefixed_name)
|
|
logger.debug("tool_rename=<%s->%s> | renamed tool", tool.name, prefixed_name)
|
|
else:
|
|
mcp_tool = MCPAgentTool(tool, self)
|
|
|
|
# Apply filters if specified
|
|
if self._should_include_tool_with_filters(mcp_tool, effective_filters):
|
|
mcp_tools.append(mcp_tool)
|
|
|
|
self._log_debug_with_thread("successfully adapted %d MCP tools", len(mcp_tools))
|
|
return PaginatedList[MCPAgentTool](mcp_tools, token=next_cursor(list_tools_response))
|
|
|
|
def list_prompts_sync(self, pagination_token: str | None = None) -> ListPromptsResult:
|
|
"""Synchronously retrieves the list of available prompts from the MCP server.
|
|
|
|
This method calls the asynchronous list_prompts method on the MCP session
|
|
and returns the raw ListPromptsResult with pagination support.
|
|
|
|
Args:
|
|
pagination_token: Optional token for pagination
|
|
|
|
Returns:
|
|
ListPromptsResult: The raw MCP response containing prompts and pagination info
|
|
"""
|
|
self._log_debug_with_thread("listing MCP prompts synchronously")
|
|
if not self._is_session_active():
|
|
raise MCPClientInitializationError(CLIENT_SESSION_NOT_RUNNING_ERROR_MESSAGE)
|
|
|
|
async def _list_prompts_async() -> ListPromptsResult:
|
|
return await cast(ClientSession, self._background_thread_session).list_prompts(
|
|
params=_pagination_params(pagination_token)
|
|
)
|
|
|
|
list_prompts_result: ListPromptsResult = self._invoke_on_background_thread(_list_prompts_async()).result()
|
|
self._log_debug_with_thread("received %d prompts from MCP server", len(list_prompts_result.prompts))
|
|
for prompt in list_prompts_result.prompts:
|
|
self._log_debug_with_thread(prompt.name)
|
|
|
|
return list_prompts_result
|
|
|
|
def get_prompt_sync(self, prompt_id: str, args: dict[str, Any]) -> GetPromptResult:
|
|
"""Synchronously retrieves a prompt from the MCP server.
|
|
|
|
Args:
|
|
prompt_id: The ID of the prompt to retrieve
|
|
args: Optional arguments to pass to the prompt
|
|
|
|
Returns:
|
|
GetPromptResult: The prompt response from the MCP server
|
|
"""
|
|
self._log_debug_with_thread("getting MCP prompt synchronously")
|
|
if not self._is_session_active():
|
|
raise MCPClientInitializationError(CLIENT_SESSION_NOT_RUNNING_ERROR_MESSAGE)
|
|
|
|
async def _get_prompt_async() -> GetPromptResult:
|
|
session = cast(ClientSession, self._background_thread_session)
|
|
return cast(GetPromptResult, await compat_get_prompt(session, prompt_id, args))
|
|
|
|
get_prompt_result: GetPromptResult = self._invoke_on_background_thread(_get_prompt_async()).result()
|
|
self._log_debug_with_thread("received prompt from MCP server")
|
|
|
|
return get_prompt_result
|
|
|
|
def list_resources_sync(self, pagination_token: str | None = None) -> ListResourcesResult:
|
|
"""Synchronously retrieves the list of available resources from the MCP server.
|
|
|
|
This method calls the asynchronous list_resources method on the MCP session
|
|
and returns the raw ListResourcesResult with pagination support.
|
|
|
|
Args:
|
|
pagination_token: Optional token for pagination
|
|
|
|
Returns:
|
|
ListResourcesResult: The raw MCP response containing resources and pagination info
|
|
"""
|
|
self._log_debug_with_thread("listing MCP resources synchronously")
|
|
if not self._is_session_active():
|
|
raise MCPClientInitializationError(CLIENT_SESSION_NOT_RUNNING_ERROR_MESSAGE)
|
|
|
|
async def _list_resources_async() -> ListResourcesResult:
|
|
return await cast(ClientSession, self._background_thread_session).list_resources(
|
|
params=_pagination_params(pagination_token)
|
|
)
|
|
|
|
list_resources_result: ListResourcesResult = self._invoke_on_background_thread(_list_resources_async()).result()
|
|
self._log_debug_with_thread("received %d resources from MCP server", len(list_resources_result.resources))
|
|
|
|
return list_resources_result
|
|
|
|
def read_resource_sync(self, uri: AnyUrl | str) -> ReadResourceResult:
|
|
"""Synchronously reads a resource from the MCP server.
|
|
|
|
Args:
|
|
uri: The URI of the resource to read
|
|
|
|
Returns:
|
|
ReadResourceResult: The resource content from the MCP server
|
|
"""
|
|
self._log_debug_with_thread("reading MCP resource synchronously: %s", uri)
|
|
if not self._is_session_active():
|
|
raise MCPClientInitializationError(CLIENT_SESSION_NOT_RUNNING_ERROR_MESSAGE)
|
|
|
|
async def _read_resource_async() -> ReadResourceResult:
|
|
# Convert string to AnyUrl if needed
|
|
resource_uri = AnyUrl(uri) if isinstance(uri, str) else uri
|
|
session = cast(ClientSession, self._background_thread_session)
|
|
return cast(ReadResourceResult, await compat_read_resource(session, resource_uri))
|
|
|
|
read_resource_result: ReadResourceResult = self._invoke_on_background_thread(_read_resource_async()).result()
|
|
self._log_debug_with_thread("received resource content from MCP server")
|
|
|
|
return read_resource_result
|
|
|
|
def list_resource_templates_sync(self, pagination_token: str | None = None) -> ListResourceTemplatesResult:
|
|
"""Synchronously retrieves the list of available resource templates from the MCP server.
|
|
|
|
Resource templates define URI patterns that can be used to access resources dynamically.
|
|
|
|
Args:
|
|
pagination_token: Optional token for pagination
|
|
|
|
Returns:
|
|
ListResourceTemplatesResult: The raw MCP response containing resource templates and pagination info
|
|
"""
|
|
self._log_debug_with_thread("listing MCP resource templates synchronously")
|
|
if not self._is_session_active():
|
|
raise MCPClientInitializationError(CLIENT_SESSION_NOT_RUNNING_ERROR_MESSAGE)
|
|
|
|
async def _list_resource_templates_async() -> ListResourceTemplatesResult:
|
|
return await cast(ClientSession, self._background_thread_session).list_resource_templates(
|
|
params=_pagination_params(pagination_token)
|
|
)
|
|
|
|
list_resource_templates_result: ListResourceTemplatesResult = self._invoke_on_background_thread(
|
|
_list_resource_templates_async()
|
|
).result()
|
|
self._log_debug_with_thread(
|
|
"received %d resource templates from MCP server", len(resource_templates(list_resource_templates_result))
|
|
)
|
|
|
|
return list_resource_templates_result
|
|
|
|
def _create_call_tool_coroutine(
|
|
self,
|
|
name: str,
|
|
arguments: dict[str, Any] | None,
|
|
read_timeout_seconds: timedelta | None,
|
|
meta: dict[str, Any] | None = None,
|
|
progress_callback: ProgressFnT | None = None,
|
|
cancellation_state: _CallCancellationState | None = None,
|
|
) -> Coroutine[Any, Any, MCPCallToolResult]:
|
|
"""Create the appropriate coroutine for calling a tool.
|
|
|
|
This method encapsulates the decision logic for whether to use task-augmented
|
|
execution or direct call_tool, returning the appropriate coroutine.
|
|
|
|
Args:
|
|
name: Name of the tool to call.
|
|
arguments: Optional arguments to pass to the tool.
|
|
read_timeout_seconds: Optional timeout for the tool call. On the mcp 2.x line, the timeout
|
|
bounds each request round of a multi round-trip tool call rather than the call as a
|
|
whole — except with task-augmented execution, where it bounds the whole task
|
|
(polling included) and each lifecycle request uses the ``tasks_config``
|
|
``request_timeout``.
|
|
meta: Optional metadata to pass to the tool call per MCP spec (_meta).
|
|
progress_callback: Optional callback to receive progress notifications.
|
|
If None, falls back to the instance-level callback set at construction time.
|
|
cancellation_state: Internal state used to cancel the exact MCP request or task.
|
|
|
|
Returns:
|
|
A coroutine that will execute the tool call.
|
|
"""
|
|
use_task = self._should_use_task(name)
|
|
effective_callback = progress_callback if progress_callback is not None else self._progress_callback
|
|
|
|
# Inject once, before branching, so both the task-augmented and direct
|
|
# call paths below carry the same enriched meta. This is safe on the
|
|
# caller's thread: asyncio.run_coroutine_threadsafe, used to hand the
|
|
# resulting coroutine to the background loop, copies the caller's
|
|
# contextvars, so the caller's active span is still current when the
|
|
# coroutine runs on the background thread.
|
|
#
|
|
# Under mcp 2.x this injection becomes redundant rather than wrong: the
|
|
# dispatcher injects its own traceparent, overwriting this one with its
|
|
# own CLIENT span. That span is parented to the caller's span, so the
|
|
# trace stays connected.
|
|
meta = inject_trace_context(meta)
|
|
|
|
if use_task:
|
|
self._log_debug_with_thread("tool=<%s> | using task-augmented execution", name)
|
|
if effective_callback is not None and not MCP_V2:
|
|
logger.warning(
|
|
"tool=<%s> | progress callbacks are ignored when task-augmented execution is enabled",
|
|
name,
|
|
)
|
|
|
|
async def _call_as_task() -> MCPCallToolResult:
|
|
if MCP_V2:
|
|
return await self._call_tool_with_task_and_poll_async(
|
|
name,
|
|
arguments,
|
|
poll_timeout=read_timeout_seconds,
|
|
meta=meta,
|
|
progress_callback=effective_callback,
|
|
cancellation_state=cancellation_state,
|
|
)
|
|
# When task-augmented execution is used, use the read_timeout_seconds parameter
|
|
# (which is a timedelta) for the polling timeout.
|
|
return await self._call_tool_as_task_and_poll_async(
|
|
name,
|
|
arguments,
|
|
poll_timeout=read_timeout_seconds,
|
|
meta=meta,
|
|
cancellation_state=cancellation_state,
|
|
)
|
|
|
|
return _call_as_task()
|
|
else:
|
|
self._log_debug_with_thread("tool=<%s> | using direct call_tool", name)
|
|
|
|
async def _call_tool_direct() -> MCPCallToolResult:
|
|
session = cast(ClientSession, self._background_thread_session)
|
|
if cancellation_state is not None:
|
|
cancellation_state["session"] = session
|
|
# Only 1.x needs the request id: the client must hand-send notifications/cancelled itself
|
|
# there. The 2.x dispatcher sends the cancel with the right id when the awaiting task is
|
|
# cancelled.
|
|
if not MCP_V2:
|
|
# MCP assigns the captured private ID synchronously at the start of
|
|
# send_request(). If that invariant changes, omit remote notification rather
|
|
# than risk cancelling a different request; local cancellation still works.
|
|
request_id = getattr(session, "_request_id", None)
|
|
if isinstance(request_id, int):
|
|
cancellation_state["request_id"] = request_id
|
|
result = await compat_call_tool(
|
|
session,
|
|
name,
|
|
arguments,
|
|
read_timeout_seconds,
|
|
effective_callback,
|
|
meta,
|
|
)
|
|
return cast(MCPCallToolResult, result)
|
|
|
|
return _call_tool_direct()
|
|
|
|
def call_tool_sync(
|
|
self,
|
|
tool_use_id: str,
|
|
name: str,
|
|
arguments: dict[str, Any] | None = None,
|
|
read_timeout_seconds: timedelta | None = None,
|
|
meta: dict[str, Any] | None = None,
|
|
progress_callback: ProgressFnT | None = None,
|
|
*,
|
|
cancel_signal: threading.Event | None = None,
|
|
) -> MCPToolResult:
|
|
"""Synchronously calls a tool on the MCP server.
|
|
|
|
This method automatically uses task-augmented execution when the client
|
|
opted in via ``tasks_config`` and the server advertises task support (on
|
|
the mcp 1.x line, the tool's ``taskSupport`` setting is also honored).
|
|
|
|
Args:
|
|
tool_use_id: Unique identifier for this tool use
|
|
name: Name of the tool to call
|
|
arguments: Optional arguments to pass to the tool
|
|
read_timeout_seconds: Optional timeout for the tool call. On the mcp 2.x line, the timeout
|
|
bounds each request round of a multi round-trip tool call rather than the call as a
|
|
whole — except with task-augmented execution, where it bounds the whole task
|
|
(polling included) and each lifecycle request uses the ``tasks_config``
|
|
``request_timeout``.
|
|
meta: Optional metadata to pass to the tool call per MCP spec (_meta)
|
|
progress_callback: Optional callback to receive progress notifications for this
|
|
call. Overrides the instance-level callback set at construction time. With
|
|
task-augmented execution, progress can only arrive before the server returns a
|
|
task handle — MCP forbids progress notifications for tasks — and the mcp 1.x
|
|
task flow ignores the callback entirely.
|
|
cancel_signal: Optional caller-owned, thread-safe event for this call. A pre-set event
|
|
cancels before the MCP request starts. If set while the request is in flight,
|
|
cancellation wins over a concurrently arriving result. The returned error result has
|
|
``cancelled=True``. Remote cancellation is best-effort and bounded; the shared MCP
|
|
session remains reusable. Clear the event before reusing it for another call.
|
|
|
|
Returns:
|
|
MCPToolResult: The tool result. Locally observed cancellation returns an error result with
|
|
``cancelled=True`` rather than raising. Cancelling the outer asyncio task is separate
|
|
and still raises ``asyncio.CancelledError`` for ``call_tool_async``.
|
|
"""
|
|
self._log_debug_with_thread("calling MCP tool '%s' synchronously with tool_use_id=%s", name, tool_use_id)
|
|
if not self._is_session_active():
|
|
raise MCPClientInitializationError(CLIENT_SESSION_NOT_RUNNING_ERROR_MESSAGE)
|
|
|
|
try:
|
|
cancellation_state = _CallCancellationState()
|
|
coro = self._create_call_tool_coroutine(
|
|
name,
|
|
arguments,
|
|
read_timeout_seconds,
|
|
meta=meta,
|
|
progress_callback=progress_callback,
|
|
cancellation_state=cancellation_state,
|
|
)
|
|
call_tool_result: MCPCallToolResult = self._invoke_on_background_thread(
|
|
coro, cancel_signal=cancel_signal, cancellation_state=cancellation_state
|
|
).result()
|
|
return self._handle_tool_result(tool_use_id, call_tool_result)
|
|
except Exception as e:
|
|
logger.exception("tool execution failed")
|
|
return self._handle_tool_execution_error(tool_use_id, e)
|
|
|
|
async def call_tool_async(
|
|
self,
|
|
tool_use_id: str,
|
|
name: str,
|
|
arguments: dict[str, Any] | None = None,
|
|
read_timeout_seconds: timedelta | None = None,
|
|
meta: dict[str, Any] | None = None,
|
|
progress_callback: ProgressFnT | None = None,
|
|
*,
|
|
cancel_signal: threading.Event | None = None,
|
|
) -> MCPToolResult:
|
|
"""Asynchronously calls a tool on the MCP server.
|
|
|
|
This method automatically uses task-augmented execution when the client
|
|
opted in via ``tasks_config`` and the server advertises task support (on
|
|
the mcp 1.x line, the tool's ``taskSupport`` setting is also honored).
|
|
|
|
Args:
|
|
tool_use_id: Unique identifier for this tool use
|
|
name: Name of the tool to call
|
|
arguments: Optional arguments to pass to the tool
|
|
read_timeout_seconds: Optional timeout for the tool call. On the mcp 2.x line, the timeout
|
|
bounds each request round of a multi round-trip tool call rather than the call as a
|
|
whole — except with task-augmented execution, where it bounds the whole task
|
|
(polling included) and each lifecycle request uses the ``tasks_config``
|
|
``request_timeout``.
|
|
meta: Optional metadata to pass to the tool call per MCP spec (_meta)
|
|
progress_callback: Optional callback to receive progress notifications for this
|
|
call. Overrides the instance-level callback set at construction time. With
|
|
task-augmented execution, progress can only arrive before the server returns a
|
|
task handle — MCP forbids progress notifications for tasks — and the mcp 1.x
|
|
task flow ignores the callback entirely.
|
|
cancel_signal: Optional caller-owned, thread-safe event for this call. A pre-set event
|
|
cancels before the MCP request starts. If set while the request is in flight,
|
|
cancellation wins over a concurrently arriving result. The returned error result has
|
|
``cancelled=True``. Remote cancellation is best-effort and bounded; the shared MCP
|
|
session remains reusable. Clear the event before reusing it for another call.
|
|
|
|
Returns:
|
|
MCPToolResult: The tool result. Locally observed cancellation returns an error result with
|
|
``cancelled=True`` rather than raising. Cancelling the outer asyncio task is separate
|
|
and still raises ``asyncio.CancelledError`` for ``call_tool_async``.
|
|
"""
|
|
self._log_debug_with_thread("calling MCP tool '%s' asynchronously with tool_use_id=%s", name, tool_use_id)
|
|
if not self._is_session_active():
|
|
raise MCPClientInitializationError(CLIENT_SESSION_NOT_RUNNING_ERROR_MESSAGE)
|
|
|
|
try:
|
|
cancellation_state = _CallCancellationState()
|
|
coro = self._create_call_tool_coroutine(
|
|
name,
|
|
arguments,
|
|
read_timeout_seconds,
|
|
meta=meta,
|
|
progress_callback=progress_callback,
|
|
cancellation_state=cancellation_state,
|
|
)
|
|
future = self._invoke_on_background_thread(
|
|
coro, cancel_signal=cancel_signal, cancellation_state=cancellation_state
|
|
)
|
|
call_tool_result: MCPCallToolResult = await asyncio.wrap_future(future)
|
|
return self._handle_tool_result(tool_use_id, call_tool_result)
|
|
except Exception as e:
|
|
logger.exception("tool execution failed")
|
|
return self._handle_tool_execution_error(tool_use_id, e)
|
|
|
|
def submit_tool_sync(
|
|
self,
|
|
name: str,
|
|
arguments: dict[str, Any] | None = None,
|
|
read_timeout_seconds: timedelta | None = None,
|
|
meta: dict[str, Any] | None = None,
|
|
progress_callback: ProgressFnT | None = None,
|
|
) -> MCPCallToolResult | MCPCreateTaskResult:
|
|
"""Submit a tool call once and return its direct result or SEP-2663 task handle.
|
|
|
|
This protocol-level operation never polls a returned task.
|
|
|
|
Args:
|
|
name: Name of the tool to call.
|
|
arguments: Optional arguments to pass to the tool.
|
|
read_timeout_seconds: Optional timeout for the request.
|
|
meta: Optional MCP request metadata.
|
|
progress_callback: Optional progress callback for the request.
|
|
|
|
Returns:
|
|
The direct tool result or server-created task handle.
|
|
|
|
Raises:
|
|
MCPClientInitializationError: If the client session is not running.
|
|
RuntimeError: If finalized task support is unavailable (mcp 1.x, missing
|
|
``tasks_config``, missing server extension, or a protocol mismatch),
|
|
validated before the request is sent.
|
|
"""
|
|
if not self._is_session_active():
|
|
raise MCPClientInitializationError(CLIENT_SESSION_NOT_RUNNING_ERROR_MESSAGE)
|
|
self._require_v2_task_lifecycle()
|
|
return self._invoke_on_background_thread(
|
|
self._call_tool_with_task_once_async(
|
|
name,
|
|
arguments,
|
|
read_timeout_seconds or self._get_task_config().get("request_timeout", DEFAULT_TASK_REQUEST_TIMEOUT),
|
|
inject_trace_context(meta),
|
|
progress_callback if progress_callback is not None else self._progress_callback,
|
|
)
|
|
).result()
|
|
|
|
async def submit_tool_async(
|
|
self,
|
|
name: str,
|
|
arguments: dict[str, Any] | None = None,
|
|
read_timeout_seconds: timedelta | None = None,
|
|
meta: dict[str, Any] | None = None,
|
|
progress_callback: ProgressFnT | None = None,
|
|
) -> MCPCallToolResult | MCPCreateTaskResult:
|
|
"""Asynchronously submit a tool call once without polling a returned task.
|
|
|
|
Args:
|
|
name: Name of the tool to call.
|
|
arguments: Optional arguments to pass to the tool.
|
|
read_timeout_seconds: Optional timeout for the request.
|
|
meta: Optional MCP request metadata.
|
|
progress_callback: Optional progress callback for the request.
|
|
|
|
Returns:
|
|
The direct tool result or server-created task handle.
|
|
|
|
Raises:
|
|
MCPClientInitializationError: If the client session is not running.
|
|
RuntimeError: If finalized task support is unavailable (mcp 1.x, missing
|
|
``tasks_config``, missing server extension, or a protocol mismatch),
|
|
validated before the request is sent.
|
|
"""
|
|
if not self._is_session_active():
|
|
raise MCPClientInitializationError(CLIENT_SESSION_NOT_RUNNING_ERROR_MESSAGE)
|
|
self._require_v2_task_lifecycle()
|
|
future = self._invoke_on_background_thread(
|
|
self._call_tool_with_task_once_async(
|
|
name,
|
|
arguments,
|
|
read_timeout_seconds or self._get_task_config().get("request_timeout", DEFAULT_TASK_REQUEST_TIMEOUT),
|
|
inject_trace_context(meta),
|
|
progress_callback if progress_callback is not None else self._progress_callback,
|
|
)
|
|
)
|
|
return await asyncio.wrap_future(future)
|
|
|
|
def get_task_sync(self, task_id: str, read_timeout_seconds: timedelta | None = None) -> MCPGetTaskResult:
|
|
"""Synchronously retrieve the current state of a SEP-2663 task.
|
|
|
|
Args:
|
|
task_id: Server-issued task identifier.
|
|
read_timeout_seconds: Optional timeout for the request.
|
|
|
|
Returns:
|
|
The task's validated current state.
|
|
|
|
Raises:
|
|
MCPClientInitializationError: If the client session is not running.
|
|
RuntimeError: If finalized task support is unavailable.
|
|
ValueError: If ``task_id`` is empty.
|
|
"""
|
|
if not self._is_session_active():
|
|
raise MCPClientInitializationError(CLIENT_SESSION_NOT_RUNNING_ERROR_MESSAGE)
|
|
if not task_id:
|
|
raise ValueError("task_id must not be empty")
|
|
return self._invoke_on_background_thread(self._get_task_async(task_id, read_timeout_seconds)).result()
|
|
|
|
async def get_task_async(self, task_id: str, read_timeout_seconds: timedelta | None = None) -> MCPGetTaskResult:
|
|
"""Asynchronously retrieve the current state of a SEP-2663 task.
|
|
|
|
Args:
|
|
task_id: Server-issued task identifier.
|
|
read_timeout_seconds: Optional timeout for the request.
|
|
|
|
Returns:
|
|
The task's validated current state.
|
|
|
|
Raises:
|
|
MCPClientInitializationError: If the client session is not running.
|
|
RuntimeError: If finalized task support is unavailable.
|
|
ValueError: If ``task_id`` is empty.
|
|
"""
|
|
if not self._is_session_active():
|
|
raise MCPClientInitializationError(CLIENT_SESSION_NOT_RUNNING_ERROR_MESSAGE)
|
|
if not task_id:
|
|
raise ValueError("task_id must not be empty")
|
|
return await asyncio.wrap_future(
|
|
self._invoke_on_background_thread(self._get_task_async(task_id, read_timeout_seconds))
|
|
)
|
|
|
|
def update_task_sync(
|
|
self,
|
|
task_id: str,
|
|
input_responses: MCPInputResponses,
|
|
read_timeout_seconds: timedelta | None = None,
|
|
) -> MCPUpdateTaskResult:
|
|
"""Synchronously supply responses to a task's outstanding input requests.
|
|
|
|
Args:
|
|
task_id: Server-issued task identifier.
|
|
input_responses: Responses keyed by the corresponding input request keys.
|
|
read_timeout_seconds: Optional timeout for the request.
|
|
|
|
Returns:
|
|
The server's validated acknowledgement.
|
|
|
|
Raises:
|
|
MCPClientInitializationError: If the client session is not running.
|
|
RuntimeError: If finalized task support is unavailable.
|
|
TypeError: If ``input_responses`` is not a dictionary.
|
|
ValueError: If ``task_id`` is empty.
|
|
"""
|
|
if not self._is_session_active():
|
|
raise MCPClientInitializationError(CLIENT_SESSION_NOT_RUNNING_ERROR_MESSAGE)
|
|
if not task_id:
|
|
raise ValueError("task_id must not be empty")
|
|
if not isinstance(input_responses, dict):
|
|
raise TypeError("input_responses must be a dictionary")
|
|
return self._invoke_on_background_thread(
|
|
self._update_task_async(task_id, input_responses, read_timeout_seconds)
|
|
).result()
|
|
|
|
async def update_task_async(
|
|
self,
|
|
task_id: str,
|
|
input_responses: MCPInputResponses,
|
|
read_timeout_seconds: timedelta | None = None,
|
|
) -> MCPUpdateTaskResult:
|
|
"""Asynchronously supply responses to a task's outstanding input requests.
|
|
|
|
Args:
|
|
task_id: Server-issued task identifier.
|
|
input_responses: Responses keyed by the corresponding input request keys.
|
|
read_timeout_seconds: Optional timeout for the request.
|
|
|
|
Returns:
|
|
The server's validated acknowledgement.
|
|
|
|
Raises:
|
|
MCPClientInitializationError: If the client session is not running.
|
|
RuntimeError: If finalized task support is unavailable.
|
|
TypeError: If ``input_responses`` is not a dictionary.
|
|
ValueError: If ``task_id`` is empty.
|
|
"""
|
|
if not self._is_session_active():
|
|
raise MCPClientInitializationError(CLIENT_SESSION_NOT_RUNNING_ERROR_MESSAGE)
|
|
if not task_id:
|
|
raise ValueError("task_id must not be empty")
|
|
if not isinstance(input_responses, dict):
|
|
raise TypeError("input_responses must be a dictionary")
|
|
return await asyncio.wrap_future(
|
|
self._invoke_on_background_thread(self._update_task_async(task_id, input_responses, read_timeout_seconds))
|
|
)
|
|
|
|
def cancel_task_sync(self, task_id: str, read_timeout_seconds: timedelta | None = None) -> MCPCancelTaskResult:
|
|
"""Synchronously request cooperative cancellation of a SEP-2663 task.
|
|
|
|
Args:
|
|
task_id: Server-issued task identifier.
|
|
read_timeout_seconds: Optional timeout for the request.
|
|
|
|
Returns:
|
|
The server's validated acknowledgement.
|
|
|
|
Raises:
|
|
MCPClientInitializationError: If the client session is not running.
|
|
RuntimeError: If finalized task support is unavailable.
|
|
ValueError: If ``task_id`` is empty.
|
|
"""
|
|
if not self._is_session_active():
|
|
raise MCPClientInitializationError(CLIENT_SESSION_NOT_RUNNING_ERROR_MESSAGE)
|
|
if not task_id:
|
|
raise ValueError("task_id must not be empty")
|
|
return self._invoke_on_background_thread(self._cancel_task_async(task_id, read_timeout_seconds)).result()
|
|
|
|
async def cancel_task_async(
|
|
self, task_id: str, read_timeout_seconds: timedelta | None = None
|
|
) -> MCPCancelTaskResult:
|
|
"""Asynchronously request cooperative cancellation of a SEP-2663 task.
|
|
|
|
Args:
|
|
task_id: Server-issued task identifier.
|
|
read_timeout_seconds: Optional timeout for the request.
|
|
|
|
Returns:
|
|
The server's validated acknowledgement.
|
|
|
|
Raises:
|
|
MCPClientInitializationError: If the client session is not running.
|
|
RuntimeError: If finalized task support is unavailable.
|
|
ValueError: If ``task_id`` is empty.
|
|
"""
|
|
if not self._is_session_active():
|
|
raise MCPClientInitializationError(CLIENT_SESSION_NOT_RUNNING_ERROR_MESSAGE)
|
|
if not task_id:
|
|
raise ValueError("task_id must not be empty")
|
|
return await asyncio.wrap_future(
|
|
self._invoke_on_background_thread(self._cancel_task_async(task_id, read_timeout_seconds))
|
|
)
|
|
|
|
def _handle_tool_execution_error(self, tool_use_id: str, exception: Exception) -> MCPToolResult:
|
|
"""Create error ToolResult with consistent logging and elicitation callback support.
|
|
|
|
Args:
|
|
tool_use_id: Unique identifier for this tool use.
|
|
exception: The exception that occurred during tool execution.
|
|
|
|
Returns:
|
|
MCPToolResult: Error result containing either the elicitation data or the
|
|
original exception message.
|
|
"""
|
|
if isinstance(exception, MCPError) and exception.error.code == -32042:
|
|
try:
|
|
error_data = ElicitationRequiredErrorData.model_validate(exception.error.data)
|
|
elicitations = [e.model_dump(exclude_none=True) for e in error_data.elicitations]
|
|
|
|
return MCPToolResult(
|
|
status="error",
|
|
toolUseId=tool_use_id,
|
|
content=[
|
|
{"text": (f"MCP Elicitation required: [{str(exception)}] with data {json.dumps(elicitations)}")}
|
|
],
|
|
)
|
|
except Exception:
|
|
logger.debug("Failed to parse ElicitationRequiredErrorData from -32042 error", exc_info=True)
|
|
|
|
if isinstance(exception, _MCPCallCancelledError):
|
|
result = MCPToolResult(
|
|
status="error",
|
|
toolUseId=tool_use_id,
|
|
content=[{"text": "Tool execution cancelled locally; remote execution may have continued"}],
|
|
cancelled=True,
|
|
)
|
|
else:
|
|
result = MCPToolResult(
|
|
status="error",
|
|
toolUseId=tool_use_id,
|
|
content=[{"text": f"Tool execution failed: {str(exception)}"}],
|
|
)
|
|
return result
|
|
|
|
def _handle_tool_result(self, tool_use_id: str, call_tool_result: MCPCallToolResult) -> MCPToolResult:
|
|
"""Maps MCP tool result to the agent's MCPToolResult format.
|
|
|
|
This method processes the content from the MCP tool call result and converts it to the format
|
|
expected by the framework.
|
|
|
|
Args:
|
|
tool_use_id: Unique identifier for this tool use
|
|
call_tool_result: The result from the MCP tool call
|
|
|
|
Returns:
|
|
MCPToolResult: The converted tool result
|
|
"""
|
|
self._log_debug_with_thread("received tool result with %d content items", len(call_tool_result.content))
|
|
|
|
# Build a typed list of ToolResultContent.
|
|
mapped_contents: list[ToolResultContent] = [
|
|
mc
|
|
for content in call_tool_result.content
|
|
if (mc := self.map_mcp_content_to_tool_result_content(content)) is not None
|
|
]
|
|
|
|
tool_error = is_error(call_tool_result)
|
|
status: ToolResultStatus = "error" if tool_error else "success"
|
|
self._log_debug_with_thread("tool execution completed with status: %s", status)
|
|
result = MCPToolResult(
|
|
status=status,
|
|
toolUseId=tool_use_id,
|
|
content=mapped_contents,
|
|
)
|
|
|
|
# `is not None`, not truthiness: the 2026-07-28 spec allows any JSON
|
|
# value here, so 0, False, "", [], and {} are all valid payloads.
|
|
structured_payload = structured_content(call_tool_result)
|
|
if structured_payload is not None:
|
|
result["structuredContent"] = structured_payload
|
|
if call_tool_result.meta:
|
|
result["metadata"] = call_tool_result.meta
|
|
if tool_error is not None:
|
|
result["isError"] = tool_error
|
|
|
|
return result
|
|
|
|
async def _async_background_thread(self) -> None:
|
|
"""Asynchronous method that runs in the background thread to manage the MCP connection.
|
|
|
|
This method establishes the transport connection, creates and initializes the MCP session,
|
|
signals readiness to the main thread, and waits for a close signal.
|
|
"""
|
|
self._log_debug_with_thread("starting async background thread for MCP connection")
|
|
|
|
# Initialized here so that it has the asyncio loop
|
|
self._close_future = asyncio.Future()
|
|
|
|
try:
|
|
async with self._transport_callable() as (read_stream, write_stream, *_):
|
|
self._log_debug_with_thread("transport connection established")
|
|
async with ClientSession(
|
|
read_stream,
|
|
write_stream,
|
|
message_handler=self._handle_session_message,
|
|
elicitation_callback=self._elicitation_callback,
|
|
client_info=(
|
|
Implementation(
|
|
name=self._application_name,
|
|
version=self._application_version or pkg_version("strands-agents"),
|
|
)
|
|
if self._application_name
|
|
else None
|
|
),
|
|
**task_session_kwargs(self._is_tasks_enabled()),
|
|
) as session:
|
|
self._log_debug_with_thread("initializing MCP session")
|
|
instructions, caps = await negotiate_session(session)
|
|
|
|
self._log_debug_with_thread("session initialized successfully")
|
|
# Store server instructions from the handshake for Host applications
|
|
self.server_instructions = instructions
|
|
# Store the session for use while we await the close event
|
|
self._background_thread_session = session
|
|
|
|
# Capabilities are exchanged during the handshake, so this is available now
|
|
self._server_task_capable = server_task_capable(caps)
|
|
self._log_debug_with_thread(
|
|
"server_task_capable=<%s> | cached server task capability", self._server_task_capable
|
|
)
|
|
|
|
async with AsyncExitStack() as session_scope:
|
|
if self._on_tools_changed is not None:
|
|
# Bounded so a server that accepts the listen request but never
|
|
# acknowledges it cannot hold start() past its startup timeout.
|
|
try:
|
|
subscription = await asyncio.wait_for(
|
|
session_scope.enter_async_context(tools_changed_subscription(session)),
|
|
timeout=self._startup_timeout,
|
|
)
|
|
except asyncio.TimeoutError as timeout_error:
|
|
raise MCPClientInitializationError(
|
|
"timed out opening the tools list-changed subscription"
|
|
) from timeout_error
|
|
self._log_debug_with_thread(
|
|
"subscribed=<%s> | tools list-changed notifications ready", subscription is not None
|
|
)
|
|
|
|
# Signal that the session has been created and is ready for use
|
|
self._init_future.set_result(None)
|
|
|
|
self._log_debug_with_thread("waiting for close signal")
|
|
# Keep background thread running until signaled to close.
|
|
# Thread is not blocked as this a future
|
|
await self._close_future
|
|
|
|
self._log_debug_with_thread("close signal received")
|
|
await self._drain_tools_refresh_tasks()
|
|
await self._drain_background_cleanup_tasks()
|
|
except Exception as e:
|
|
# If we encounter an exception and the future is still running,
|
|
# it means it was encountered during the initialization phase.
|
|
if not self._init_future.done():
|
|
self._init_future.set_exception(e)
|
|
else:
|
|
# _close_future is automatically cancelled by the framework which doesn't provide us with the useful
|
|
# exception, so instead we store the exception in a different field where stop() can read it
|
|
self._close_exception = e
|
|
if self._close_future and not self._close_future.done():
|
|
self._close_future.set_result(None)
|
|
|
|
self._log_debug_with_thread(
|
|
"encountered exception on background thread after initialization %s", str(e)
|
|
)
|
|
|
|
# Raise an exception if the underlying client raises an exception in a message
|
|
# This happens when the underlying client has an http timeout error
|
|
async def _handle_session_message(self, message: Exception | Any) -> None:
|
|
if isinstance(message, Exception):
|
|
error_msg = str(message).lower()
|
|
if any(pattern in error_msg for pattern in _NON_FATAL_ERROR_PATTERNS):
|
|
self._log_debug_with_thread("ignoring non-fatal MCP session error: %s", message)
|
|
else:
|
|
raise message
|
|
elif is_tools_list_changed(message) and self._on_tools_changed is not None:
|
|
self._log_debug_with_thread("received tools list-changed notification")
|
|
refresh_task = asyncio.create_task(self._handle_tools_changed())
|
|
self._tools_refresh_tasks.add(refresh_task)
|
|
refresh_task.add_done_callback(self._tools_refresh_tasks.discard)
|
|
await anyio.lowlevel.checkpoint()
|
|
|
|
async def _handle_tools_changed(self) -> None:
|
|
"""Refresh the tool list after a list-changed notification.
|
|
|
|
Runs on the background event loop. One refresh runs at a time;
|
|
notifications that arrive while a round is sleeping or refreshing
|
|
fold into a single trailing rerun, so a burst costs at most two
|
|
refreshes.
|
|
"""
|
|
if self._tools_refresh_in_progress:
|
|
self._tools_refresh_pending = True
|
|
return
|
|
self._tools_refresh_in_progress = True
|
|
try:
|
|
while True:
|
|
self._tools_refresh_pending = False
|
|
await asyncio.sleep(_TOOLS_CHANGED_DEBOUNCE_SECONDS)
|
|
try:
|
|
await asyncio.to_thread(self._refresh_loaded_tools)
|
|
except Exception as error:
|
|
# One failed round must not discard a queued trailing rerun.
|
|
logger.warning("error=<%s> | failed to refresh tools after list-changed notification", error)
|
|
if not self._tools_refresh_pending:
|
|
return
|
|
finally:
|
|
self._tools_refresh_in_progress = False
|
|
|
|
def _refresh_loaded_tools(self) -> None:
|
|
"""Re-list every tool and invoke the change callback.
|
|
|
|
Runs off the event loop thread because `list_tools_sync` blocks on
|
|
work it submits back to the background loop.
|
|
"""
|
|
previous_names = [tool.tool_name for tool in self._loaded_tools or []]
|
|
refreshed_tools = self._list_all_tools_sync()
|
|
self._loaded_tools = refreshed_tools
|
|
self._log_debug_with_thread("refreshed_tools=<%d> | tools refresh complete", len(refreshed_tools))
|
|
callback = self._on_tools_changed
|
|
if callback is None:
|
|
return
|
|
try:
|
|
callback(previous_names, refreshed_tools)
|
|
except Exception:
|
|
# The cache is already refreshed; only the notification to the
|
|
# consumer failed, so name the callback rather than the refresh.
|
|
logger.warning("the on_tools_changed callback raised", exc_info=True)
|
|
|
|
def _background_task(self) -> None:
|
|
"""Sets up and runs the event loop in the background thread.
|
|
|
|
This method creates a new event loop for the background thread,
|
|
sets it as the current event loop, and runs the async_background_thread
|
|
coroutine until completion. In this case "until completion" means until the _close_future is resolved.
|
|
This allows for a long-running event loop.
|
|
"""
|
|
self._log_debug_with_thread("setting up background task event loop")
|
|
# Clear any running-loop state leaked by OpenTelemetry's ThreadingInstrumentor, which wraps Thread.run()
|
|
# and can propagate the parent thread's event loop reference, causing run_until_complete() to fail.
|
|
asyncio._set_running_loop(None)
|
|
self._background_thread_event_loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(self._background_thread_event_loop)
|
|
self._background_thread_event_loop.run_until_complete(self._async_background_thread())
|
|
|
|
def map_mcp_content_to_tool_result_content(
|
|
self,
|
|
content: MCPTextContent | MCPImageContent | MCPEmbeddedResource | Any,
|
|
) -> ToolResultContent | None:
|
|
"""Maps MCP content types to tool result content types.
|
|
|
|
This method converts MCP-specific content types to the generic
|
|
ToolResultContent format used by the agent framework. Subclasses can
|
|
override this to intercept or transform specific content blocks before
|
|
they reach the model.
|
|
|
|
Args:
|
|
content: The MCP content to convert
|
|
|
|
Returns:
|
|
ToolResultContent or None: The converted content, or None if the content type is not supported
|
|
"""
|
|
if isinstance(content, MCPTextContent):
|
|
self._log_debug_with_thread("mapping MCP text content")
|
|
return {"text": content.text}
|
|
elif isinstance(content, MCPImageContent):
|
|
image_mime = cast(str, mime_type(content))
|
|
self._log_debug_with_thread("mapping MCP image content with mime type: %s", image_mime)
|
|
return {
|
|
"image": {
|
|
"format": MIME_TO_FORMAT[image_mime],
|
|
"source": {"bytes": base64.b64decode(content.data)},
|
|
}
|
|
}
|
|
elif isinstance(content, MCPEmbeddedResource):
|
|
"""
|
|
TODO: Include URI information in results.
|
|
Models may find it useful to be aware not only of the information,
|
|
but the location of the information too.
|
|
|
|
This may be difficult without taking an opinionated position. For example,
|
|
a content block may need to indicate that the following Image content block
|
|
is of particular URI.
|
|
"""
|
|
|
|
self._log_debug_with_thread("mapping MCP embedded resource content")
|
|
|
|
resource = content.resource
|
|
if isinstance(resource, TextResourceContents):
|
|
return {"text": resource.text}
|
|
elif isinstance(resource, BlobResourceContents):
|
|
try:
|
|
raw_bytes = base64.b64decode(resource.blob)
|
|
except Exception:
|
|
self._log_debug_with_thread("embedded resource blob could not be decoded - dropping")
|
|
return None
|
|
|
|
resource_mime = mime_type(resource)
|
|
if resource_mime and (
|
|
resource_mime.startswith("text/")
|
|
or resource_mime
|
|
in (
|
|
"application/json",
|
|
"application/xml",
|
|
"application/javascript",
|
|
"application/yaml",
|
|
"application/x-yaml",
|
|
)
|
|
or resource_mime.endswith(("+json", "+xml"))
|
|
):
|
|
try:
|
|
return {"text": raw_bytes.decode("utf-8", errors="replace")}
|
|
except Exception:
|
|
pass
|
|
|
|
if resource_mime in MIME_TO_FORMAT:
|
|
return {
|
|
"image": {
|
|
"format": MIME_TO_FORMAT[resource_mime],
|
|
"source": {"bytes": raw_bytes},
|
|
}
|
|
}
|
|
|
|
self._log_debug_with_thread("embedded resource blob with non-textual/unknown mimeType - dropping")
|
|
return None
|
|
|
|
return None # type: ignore[unreachable] # Defensive: future MCP resource types
|
|
else:
|
|
self._log_debug_with_thread("unhandled content type: %s - dropping content", content.__class__.__name__)
|
|
return None
|
|
|
|
def _log_debug_with_thread(self, msg: str, *args: Any, **kwargs: Any) -> None:
|
|
"""Logger helper to help differentiate logs coming from MCPClient background thread."""
|
|
formatted_msg = msg % args if args else msg
|
|
logger.debug(
|
|
"[Thread: %s, Session: %s] %s", threading.current_thread().name, self._session_id, formatted_msg, **kwargs
|
|
)
|
|
|
|
def _track_background_cleanup_task(self, task: asyncio.Task[Any]) -> None:
|
|
"""Retain a detached cleanup task while the MCP session can still service it."""
|
|
if not self._accept_background_cleanup_tasks:
|
|
task.cancel()
|
|
task.add_done_callback(lambda done: done.exception() if not done.cancelled() else None)
|
|
return
|
|
self._background_cleanup_tasks.add(task)
|
|
task.add_done_callback(self._background_cleanup_tasks.discard)
|
|
|
|
async def _drain_tools_refresh_tasks(self) -> None:
|
|
"""Let in-flight tool refreshes unwind before the background loop stops.
|
|
|
|
A refresh worker thread blocks on a future served by this loop; once
|
|
the close signal has fired, that future resolves promptly, so waiting
|
|
here keeps the worker from being stranded on a future a stopped loop
|
|
can never resolve (a stranded worker is a non-daemon thread that
|
|
hangs interpreter exit).
|
|
"""
|
|
if not self._tools_refresh_tasks:
|
|
return
|
|
pending_refreshes = set(self._tools_refresh_tasks)
|
|
_, still_pending = await asyncio.wait(pending_refreshes, timeout=1)
|
|
for task in still_pending:
|
|
task.cancel()
|
|
if still_pending:
|
|
await asyncio.wait(still_pending, timeout=1)
|
|
|
|
async def _drain_background_cleanup_tasks(self) -> None:
|
|
"""Give detached MCP cancellation cleanup a bounded window before session shutdown."""
|
|
loop = asyncio.get_running_loop()
|
|
deadline = loop.time() + 1
|
|
while self._background_cleanup_tasks and loop.time() < deadline:
|
|
timeout = max(0, deadline - loop.time())
|
|
await asyncio.wait(set(self._background_cleanup_tasks), timeout=timeout)
|
|
|
|
# Stop delayed callbacks from enqueueing work after the session has started closing.
|
|
self._accept_background_cleanup_tasks = False
|
|
pending = set(self._background_cleanup_tasks)
|
|
for task in pending:
|
|
task.cancel()
|
|
if pending:
|
|
done, still_pending = await asyncio.wait(pending, timeout=0.1)
|
|
for task in done:
|
|
if not task.cancelled():
|
|
task.exception()
|
|
for task in still_pending:
|
|
task.add_done_callback(lambda done: done.exception() if not done.cancelled() else None)
|
|
self._background_cleanup_tasks.clear()
|
|
|
|
async def _cancel_tool_call(self, cancellation_state: _CallCancellationState) -> None:
|
|
"""Cancel the exact MCP task or request represented by the per-call state.
|
|
|
|
Hand-sending `notifications/cancelled` is 1.x-only work: on 2.x no request id is captured, so this returns
|
|
without sending, and the dispatcher sends the cancel itself when the task awaiting the request is cancelled
|
|
(SEP-2575 scopes hand-sent cancellation to STDIO).
|
|
"""
|
|
session = cancellation_state.get("session")
|
|
if session is None or cancellation_state.get("notification_sent"):
|
|
return
|
|
|
|
try:
|
|
cancellation: Coroutine[Any, Any, Any]
|
|
if task_id := cancellation_state.get("task_id"):
|
|
if MCP_V2:
|
|
cancellation = self._cancel_task_async(task_id)
|
|
else:
|
|
cancellation = session.experimental.cancel_task(task_id) # type: ignore[attr-defined]
|
|
elif (request_id := cancellation_state.get("request_id")) is not None:
|
|
cancellation = session.send_notification(
|
|
ClientNotification(
|
|
CancelledNotification( # type: ignore[operator]
|
|
params=CancelledNotificationParams(
|
|
requestId=request_id, # type: ignore[call-arg]
|
|
reason="Strands agent invocation cancelled",
|
|
)
|
|
)
|
|
)
|
|
)
|
|
else:
|
|
return
|
|
cancellation_state["notification_sent"] = True
|
|
cancellation_task = asyncio.create_task(cancellation)
|
|
self._track_background_cleanup_task(cancellation_task)
|
|
done, pending = await asyncio.wait({cancellation_task}, timeout=1)
|
|
if done:
|
|
await cancellation_task
|
|
else:
|
|
cancellation_task.cancel()
|
|
cancellation_task.add_done_callback(lambda task: task.exception() if not task.cancelled() else None)
|
|
except Exception as error:
|
|
self._log_debug_with_thread("error=<%s> | failed to notify MCP server of cancellation", str(error))
|
|
|
|
def _invoke_on_background_thread(
|
|
self,
|
|
coro: Coroutine[Any, Any, T],
|
|
cancel_signal: threading.Event | None = None,
|
|
cancellation_state: _CallCancellationState | None = None,
|
|
) -> futures.Future[T]:
|
|
# save a reference to this so that even if it's reset we have the original
|
|
close_future = self._close_future
|
|
|
|
if (
|
|
self._background_thread_session is None
|
|
or self._background_thread_event_loop is None
|
|
or close_future is None
|
|
):
|
|
raise MCPClientInitializationError("the client session was not initialized")
|
|
|
|
async def run_async() -> T:
|
|
if cancel_signal is not None and cancel_signal.is_set():
|
|
coro.close()
|
|
raise _MCPCallCancelledError("Tool execution cancelled")
|
|
|
|
invoke_event = asyncio.create_task(coro)
|
|
invoke_cancel_requested = False
|
|
cancel_event: asyncio.Task[None] | None = None
|
|
|
|
if cancel_signal is not None:
|
|
|
|
async def wait_for_cancel() -> None:
|
|
# threading.Event has no async notification hook. Poll on this loop rather than
|
|
# running Event.wait() in an executor: cancelling that await cannot stop a worker
|
|
# already blocked in Event.wait(), so successful calls could strand worker threads.
|
|
while not cancel_signal.is_set():
|
|
await asyncio.sleep(0.05)
|
|
|
|
cancel_event = asyncio.create_task(wait_for_cancel())
|
|
|
|
tasks: list[asyncio.Task[Any] | asyncio.Future[Any]] = [invoke_event, close_future]
|
|
if cancel_event is not None:
|
|
tasks.append(cancel_event)
|
|
|
|
try:
|
|
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
|
|
if cancel_signal is not None and cancel_signal.is_set():
|
|
self._log_debug_with_thread("cancellation detected during MCP invocation")
|
|
invoke_cancel_requested = True
|
|
invoke_event.cancel()
|
|
_, pending = await asyncio.wait({invoke_event}, timeout=1)
|
|
if pending:
|
|
self._log_debug_with_thread("timed out cleaning up cancelled MCP invocation")
|
|
if cancellation_state is not None:
|
|
await self._cancel_tool_call(cancellation_state)
|
|
raise _MCPCallCancelledError("Tool execution cancelled")
|
|
if invoke_event in done:
|
|
return await invoke_event
|
|
|
|
self._log_debug_with_thread("event loop for the server closed before the invoke completed")
|
|
invoke_event.cancel()
|
|
await asyncio.wait({invoke_event}, timeout=1)
|
|
raise RuntimeError("Connection to the MCP server was closed")
|
|
finally:
|
|
if not invoke_event.done() and not invoke_cancel_requested:
|
|
invoke_event.cancel()
|
|
# asyncio.wait reports expiration through pending; it does not raise TimeoutError.
|
|
# The original caller cancellation or exception continues after this bounded cleanup.
|
|
_, pending = await asyncio.wait({invoke_event}, timeout=1)
|
|
if pending:
|
|
self._log_debug_with_thread("MCP invocation did not finish within bounded cancellation cleanup")
|
|
if cancellation_state is not None:
|
|
await self._cancel_tool_call(cancellation_state)
|
|
else:
|
|
pending = {invoke_event} if not invoke_event.done() else set()
|
|
if pending:
|
|
self._track_background_cleanup_task(invoke_event)
|
|
if cancel_event is not None and not cancel_event.done():
|
|
cancel_event.cancel()
|
|
await asyncio.gather(cancel_event, return_exceptions=True)
|
|
|
|
invoke_future = asyncio.run_coroutine_threadsafe(coro=run_async(), loop=self._background_thread_event_loop)
|
|
return invoke_future
|
|
|
|
def _should_include_tool_with_filters(self, tool: MCPAgentTool, filters: ToolFilters | None) -> bool:
|
|
"""Check if a tool should be included based on provided filters."""
|
|
if not filters:
|
|
return True
|
|
|
|
# Apply allowed filter
|
|
if "allowed" in filters:
|
|
if not self._matches_patterns(tool, filters["allowed"]):
|
|
return False
|
|
|
|
# Apply rejected filter
|
|
if "rejected" in filters:
|
|
if self._matches_patterns(tool, filters["rejected"]):
|
|
return False
|
|
|
|
return True
|
|
|
|
def _matches_patterns(self, tool: MCPAgentTool, patterns: list[_ToolMatcher]) -> bool:
|
|
"""Check if tool matches any of the given patterns."""
|
|
for pattern in patterns:
|
|
if callable(pattern):
|
|
if pattern(tool):
|
|
return True
|
|
elif isinstance(pattern, Pattern):
|
|
if pattern.match(tool.mcp_tool.name):
|
|
return True
|
|
elif isinstance(pattern, str):
|
|
if pattern == tool.mcp_tool.name:
|
|
return True
|
|
return False
|
|
|
|
def _is_session_active(self) -> bool:
|
|
if self._background_thread is None or not self._background_thread.is_alive():
|
|
return False
|
|
|
|
if self._close_future is not None and self._close_future.done():
|
|
return False
|
|
|
|
return True
|
|
|
|
def _is_tasks_enabled(self) -> bool:
|
|
"""Check if tasks feature is enabled.
|
|
|
|
Tasks are enabled if tasks config is defined and not None.
|
|
|
|
Returns:
|
|
True if task-augmented execution is enabled, False otherwise.
|
|
"""
|
|
return self._tasks_config is not None
|
|
|
|
def _get_task_config(self) -> TasksConfig:
|
|
"""Returns the task execution configuration, configured with defaults if not specified."""
|
|
task_config = self._tasks_config or DEFAULT_TASK_CONFIG
|
|
return TasksConfig(
|
|
ttl=task_config.get("ttl", DEFAULT_TASK_TTL),
|
|
poll_timeout=task_config.get("poll_timeout", DEFAULT_TASK_POLL_TIMEOUT),
|
|
request_timeout=task_config.get("request_timeout", DEFAULT_TASK_REQUEST_TIMEOUT),
|
|
poll_interval=task_config.get("poll_interval", DEFAULT_TASK_POLL_INTERVAL),
|
|
)
|
|
|
|
def _has_server_task_support(self) -> bool:
|
|
"""Check if the MCP server supports task-augmented tool calls.
|
|
|
|
Returns the capability value that was cached immediately after session initialization.
|
|
Server capabilities are exchanged during the MCP handshake, so this is available
|
|
as soon as start() completes.
|
|
|
|
Returns:
|
|
True if server supports task-augmented tool calls, False otherwise.
|
|
"""
|
|
return self._server_task_capable or False
|
|
|
|
def _should_use_task(self, tool_name: str) -> bool:
|
|
"""Determine if task-augmented execution should be used for a tool.
|
|
|
|
Task-augmented execution requires:
|
|
1. tasks config is enabled (opt-in check)
|
|
2. Server supports tasks (capability check)
|
|
3. Tool taskSupport is 'required' or 'optional'
|
|
|
|
Args:
|
|
tool_name: Name of the tool to check.
|
|
|
|
Returns:
|
|
True if task-augmented execution should be used, False otherwise.
|
|
"""
|
|
# Opt-in check: tasks must be explicitly enabled via tasks config
|
|
if not self._is_tasks_enabled():
|
|
return False
|
|
|
|
if MCP_V2:
|
|
return self._has_server_task_support()
|
|
|
|
# Local import to avoid errors on old SDK versions that don't support Tasks
|
|
from mcp.types import TASK_OPTIONAL, TASK_REQUIRED # type: ignore[attr-defined]
|
|
|
|
# Server capability check (per MCP spec)
|
|
if not self._has_server_task_support():
|
|
return False
|
|
|
|
# Tool-level capability check (cached during list_tools_sync)
|
|
task_support = self._tool_task_support_cache.get(tool_name)
|
|
|
|
# Use tasks for TASK_REQUIRED or TASK_OPTIONAL when server supports
|
|
if task_support == TASK_REQUIRED or task_support == TASK_OPTIONAL:
|
|
return True
|
|
|
|
# Default: 'forbidden', None, or unknown -> don't use tasks
|
|
return False
|
|
|
|
def _create_task_error_result(self, message: str) -> MCPCallToolResult:
|
|
"""Create an error MCPCallToolResult with consistent formatting.
|
|
|
|
This helper reduces duplication in task error handling paths.
|
|
|
|
Args:
|
|
message: The error message to include in the result.
|
|
|
|
Returns:
|
|
MCPCallToolResult with isError=True and the message as text content.
|
|
"""
|
|
return MCPCallToolResult( # type: ignore[call-arg]
|
|
isError=True,
|
|
content=[MCPTextContent(type="text", text=message)],
|
|
)
|
|
|
|
# ==================================================================================
|
|
# Task-Augmented Tool Execution
|
|
# ==================================================================================
|
|
#
|
|
# MCP 2.x uses finalized SEP-2663: tools/call returns an immediate result or task,
|
|
# followed by tasks/get, tasks/update, and tasks/cancel as needed. MCP 1.x retains
|
|
# the legacy 2025-11-25 workflow implemented below the MCP 2.x lifecycle.
|
|
# ==================================================================================
|
|
|
|
def _require_v2_task_lifecycle(self) -> ClientSession:
|
|
"""Return the active session after validating SEP-2663 availability."""
|
|
if not MCP_V2:
|
|
raise RuntimeError("SEP-2663 task operations require mcp 2.x")
|
|
if not self._is_tasks_enabled():
|
|
raise RuntimeError("SEP-2663 task operations require MCPClient tasks_config")
|
|
if not self._has_server_task_support():
|
|
raise RuntimeError(f"MCP server did not advertise the {_TASKS_EXTENSION} extension")
|
|
|
|
session = cast(ClientSession, self._background_thread_session)
|
|
if getattr(session, "protocol_version", None) != _TASKS_PROTOCOL_VERSION:
|
|
raise RuntimeError(f"SEP-2663 task operations require negotiated MCP protocol {_TASKS_PROTOCOL_VERSION}")
|
|
return session
|
|
|
|
async def _call_tool_with_task_once_async(
|
|
self,
|
|
name: str,
|
|
arguments: dict[str, Any] | None,
|
|
read_timeout_seconds: timedelta | None,
|
|
meta: dict[str, Any] | None,
|
|
progress_callback: ProgressFnT | None,
|
|
) -> MCPCallToolResult | MCPCreateTaskResult:
|
|
"""Invoke MCP 2.x ``tools/call`` and resolve any core input-required rounds."""
|
|
if not MCP_V2:
|
|
raise RuntimeError("SEP-2663 task-aware tool calls require mcp 2.x")
|
|
|
|
result = await compat_call_tool(
|
|
cast(ClientSession, self._background_thread_session),
|
|
name,
|
|
arguments,
|
|
read_timeout_seconds,
|
|
progress_callback,
|
|
meta,
|
|
allow_claimed=True,
|
|
)
|
|
return cast(MCPCallToolResult | MCPCreateTaskResult, result)
|
|
|
|
async def _get_task_async(self, task_id: str, read_timeout_seconds: timedelta | None = None) -> MCPGetTaskResult:
|
|
"""Send a finalized ``tasks/get`` request."""
|
|
session = cast(Any, self._require_v2_task_lifecycle())
|
|
timeout = read_timeout_seconds or self._get_task_config().get("request_timeout", DEFAULT_TASK_REQUEST_TIMEOUT)
|
|
result = cast(
|
|
MCPGetTaskResult,
|
|
await session.send_request(
|
|
_GetTaskRequest(params=_GetTaskRequestParams(task_id=task_id)),
|
|
MCPGetTaskResult,
|
|
request_read_timeout_seconds=read_timeout(timeout),
|
|
),
|
|
)
|
|
if result.task_id != task_id:
|
|
raise ValueError("MCP tasks/get response returned a different taskId")
|
|
return result
|
|
|
|
async def _update_task_async(
|
|
self,
|
|
task_id: str,
|
|
input_responses: MCPInputResponses,
|
|
read_timeout_seconds: timedelta | None = None,
|
|
) -> MCPUpdateTaskResult:
|
|
"""Send a finalized ``tasks/update`` request."""
|
|
session = cast(Any, self._require_v2_task_lifecycle())
|
|
timeout = read_timeout_seconds or self._get_task_config().get("request_timeout", DEFAULT_TASK_REQUEST_TIMEOUT)
|
|
return cast(
|
|
MCPUpdateTaskResult,
|
|
await session.send_request(
|
|
_UpdateTaskRequest(params=_UpdateTaskRequestParams(task_id=task_id, input_responses=input_responses)),
|
|
MCPUpdateTaskResult,
|
|
request_read_timeout_seconds=read_timeout(timeout),
|
|
),
|
|
)
|
|
|
|
async def _cancel_task_async(
|
|
self, task_id: str, read_timeout_seconds: timedelta | None = None
|
|
) -> MCPCancelTaskResult:
|
|
"""Send a finalized ``tasks/cancel`` request."""
|
|
session = cast(Any, self._require_v2_task_lifecycle())
|
|
timeout = read_timeout_seconds or self._get_task_config().get("request_timeout", DEFAULT_TASK_REQUEST_TIMEOUT)
|
|
return cast(
|
|
MCPCancelTaskResult,
|
|
await session.send_request(
|
|
_CancelTaskRequest(params=_CancelTaskRequestParams(task_id=task_id)),
|
|
MCPCancelTaskResult,
|
|
request_read_timeout_seconds=read_timeout(timeout),
|
|
),
|
|
)
|
|
|
|
async def _fulfill_task_input(
|
|
self,
|
|
task_id: str,
|
|
key: str,
|
|
request: MCPInputRequest,
|
|
read_timeout_seconds: timedelta,
|
|
) -> None:
|
|
"""Fulfill one input request carried by an input-required task."""
|
|
import mcp.types as mcp_types
|
|
from mcp.client.session import ClientRequestContext # type: ignore[attr-defined]
|
|
|
|
session = cast(Any, self._require_v2_task_lifecycle())
|
|
params = getattr(request, "params", None)
|
|
context = ClientRequestContext(
|
|
session=session,
|
|
request_id=key,
|
|
meta=getattr(params, "meta", None),
|
|
)
|
|
response = await session.dispatch_input_request(context, request)
|
|
error_data_type = mcp_types.ErrorData
|
|
if isinstance(response, error_data_type):
|
|
raise MCPError.from_error_data(response)
|
|
await self._update_task_async(task_id, {key: response}, read_timeout_seconds)
|
|
|
|
def _resolve_v2_terminal_state(self, state: MCPGetTaskResult) -> MCPCallToolResult | None:
|
|
"""Map a terminal task state to its final tool result, or None when not terminal."""
|
|
if state.status == "completed":
|
|
return cast(MCPCallToolResult, state.result)
|
|
if state.status == "failed":
|
|
error = cast(MCPTaskError, state.error)
|
|
message = f"{error.message}: {state.status_message}" if state.status_message else error.message
|
|
return self._create_task_error_result(message)
|
|
if state.status == "cancelled":
|
|
return self._create_task_error_result(state.status_message or "Task was cancelled")
|
|
return None
|
|
|
|
async def _fulfill_v2_input_requests(
|
|
self,
|
|
task_id: str,
|
|
state: MCPGetTaskResult,
|
|
answered_input_keys: set[str],
|
|
request_timeout: timedelta,
|
|
) -> None:
|
|
"""Answer each not-yet-answered input request reported by the task."""
|
|
for key, request in (state.input_requests or {}).items():
|
|
if key in answered_input_keys:
|
|
continue
|
|
await self._fulfill_task_input(task_id, key, request, request_timeout)
|
|
answered_input_keys.add(key)
|
|
|
|
async def _complete_v2_task(
|
|
self,
|
|
task: MCPCreateTaskResult,
|
|
cancellation_state: _CallCancellationState | None,
|
|
) -> MCPCallToolResult:
|
|
"""Poll a SEP-2663 task and return its final nested tool result."""
|
|
config = self._get_task_config()
|
|
request_timeout = config.get("request_timeout", DEFAULT_TASK_REQUEST_TIMEOUT)
|
|
default_poll_interval = config.get("poll_interval", DEFAULT_TASK_POLL_INTERVAL)
|
|
current: MCPCreateTaskResult | MCPGetTaskResult = task
|
|
stale_read = False
|
|
answered_input_keys: set[str] = set()
|
|
|
|
if cancellation_state is not None:
|
|
cancellation_state["task_id"] = task.task_id
|
|
|
|
while True:
|
|
if isinstance(current, MCPGetTaskResult):
|
|
terminal_result = self._resolve_v2_terminal_state(current)
|
|
if terminal_result is not None:
|
|
return terminal_result
|
|
if current.status == "input_required":
|
|
await self._fulfill_v2_input_requests(task.task_id, current, answered_input_keys, request_timeout)
|
|
|
|
poll_interval_ms = current.poll_interval_ms
|
|
poll_interval = (
|
|
timedelta(milliseconds=poll_interval_ms) if poll_interval_ms is not None else default_poll_interval
|
|
)
|
|
if stale_read or current.status == "working" or isinstance(current, MCPGetTaskResult):
|
|
await asyncio.sleep(max(0.01, poll_interval.total_seconds()))
|
|
|
|
next_state = await self._get_task_async(task.task_id, request_timeout)
|
|
current = self._reconcile_v2_task_state(current, next_state)
|
|
stale_read = current is not next_state
|
|
|
|
@staticmethod
|
|
def _reconcile_v2_task_state(
|
|
previous: MCPCreateTaskResult | MCPGetTaskResult,
|
|
next_state: MCPGetTaskResult,
|
|
) -> MCPCreateTaskResult | MCPGetTaskResult:
|
|
"""Reject contradictory task updates and keep the newer of two observed states.
|
|
|
|
Timestamps are compared as instants, not strings, so a server spelling
|
|
the same moment differently (``Z`` vs ``+00:00``, varying fractional
|
|
precision) is not treated as a contradiction. A stale read returns the
|
|
previous state so the caller re-polls instead of failing.
|
|
"""
|
|
if _parse_task_timestamp(next_state.created_at) != _parse_task_timestamp(previous.created_at):
|
|
raise ValueError("MCP task response changed createdAt")
|
|
|
|
if _parse_task_timestamp(next_state.last_updated_at) < _parse_task_timestamp(previous.last_updated_at):
|
|
return previous
|
|
|
|
return next_state
|
|
|
|
async def _create_v2_task(
|
|
self,
|
|
name: str,
|
|
arguments: dict[str, Any] | None,
|
|
meta: dict[str, Any] | None,
|
|
progress_callback: ProgressFnT | None,
|
|
task_state: _CallCancellationState,
|
|
) -> MCPCallToolResult | MCPCreateTaskResult:
|
|
"""Send the SEP-2663 create round without losing a task to cancellation.
|
|
|
|
The round is shielded so a cancellation arriving while ``tools/call``
|
|
is in flight cannot discard a task the server already created: a
|
|
handle that lands within the grace period is cancelled immediately,
|
|
and a later-arriving one is cancelled by a detached callback.
|
|
"""
|
|
create_task = asyncio.create_task(
|
|
self._call_tool_with_task_once_async(
|
|
name,
|
|
arguments,
|
|
self._get_task_config().get("request_timeout", DEFAULT_TASK_REQUEST_TIMEOUT),
|
|
meta,
|
|
progress_callback,
|
|
)
|
|
)
|
|
try:
|
|
result = await asyncio.shield(create_task)
|
|
except asyncio.CancelledError:
|
|
done, _ = await asyncio.wait({create_task}, timeout=1)
|
|
if done:
|
|
self._record_v2_task_handle(create_task, task_state)
|
|
if task_state.get("task_id") is not None:
|
|
await self._cancel_tool_call(task_state)
|
|
else:
|
|
|
|
def cancel_delayed_create(finished: "asyncio.Task[MCPCallToolResult | MCPCreateTaskResult]") -> None:
|
|
self._record_v2_task_handle(finished, task_state)
|
|
if task_state.get("task_id") is not None:
|
|
cleanup_task = asyncio.create_task(self._cancel_tool_call(task_state))
|
|
self._track_background_cleanup_task(cleanup_task)
|
|
|
|
self._track_background_cleanup_task(create_task)
|
|
create_task.add_done_callback(cancel_delayed_create)
|
|
raise
|
|
if isinstance(result, MCPCreateTaskResult):
|
|
task_state["task_id"] = result.task_id
|
|
return result
|
|
|
|
@staticmethod
|
|
def _record_v2_task_handle(
|
|
create_task: "asyncio.Task[MCPCallToolResult | MCPCreateTaskResult]",
|
|
task_state: _CallCancellationState,
|
|
) -> None:
|
|
"""Record the task id from a finished create round, if it produced a task."""
|
|
if create_task.cancelled() or create_task.exception() is not None:
|
|
return
|
|
result = create_task.result()
|
|
if isinstance(result, MCPCreateTaskResult):
|
|
task_state["task_id"] = result.task_id
|
|
|
|
async def _call_tool_with_task_and_poll_async(
|
|
self,
|
|
name: str,
|
|
arguments: dict[str, Any] | None = None,
|
|
poll_timeout: timedelta | None = None,
|
|
meta: dict[str, Any] | None = None,
|
|
progress_callback: ProgressFnT | None = None,
|
|
cancellation_state: _CallCancellationState | None = None,
|
|
) -> MCPCallToolResult:
|
|
"""Call a tool and automatically complete a returned SEP-2663 task.
|
|
|
|
The lifecycle is validated before the create request is sent, and the
|
|
server task is cancelled on every exit other than success once its
|
|
handle exists.
|
|
"""
|
|
timeout = poll_timeout or self._get_task_config().get("poll_timeout", DEFAULT_TASK_POLL_TIMEOUT)
|
|
task_state = cancellation_state if cancellation_state is not None else _CallCancellationState()
|
|
task_state["session"] = cast(ClientSession, self._background_thread_session)
|
|
|
|
async def execute() -> MCPCallToolResult:
|
|
self._require_v2_task_lifecycle()
|
|
result = await self._create_v2_task(name, arguments, meta, progress_callback, task_state)
|
|
if isinstance(result, MCPCallToolResult):
|
|
return result
|
|
return await self._complete_v2_task(result, task_state)
|
|
|
|
try:
|
|
return await asyncio.wait_for(execute(), timeout=timeout.total_seconds())
|
|
except asyncio.TimeoutError:
|
|
task_id = task_state.get("task_id")
|
|
if task_id is not None:
|
|
await self._cancel_tool_call(task_state)
|
|
return self._create_task_error_result(
|
|
f"Task {task_id or '<pending>'} timed out after {timeout.total_seconds()} seconds"
|
|
)
|
|
except BaseException:
|
|
if task_state.get("task_id") is not None:
|
|
await self._cancel_tool_call(task_state)
|
|
raise
|
|
|
|
async def _call_tool_as_task_and_poll_async(
|
|
self,
|
|
name: str,
|
|
arguments: dict[str, Any] | None = None,
|
|
ttl: timedelta | None = None,
|
|
poll_timeout: timedelta | None = None,
|
|
meta: dict[str, Any] | None = None,
|
|
cancellation_state: _CallCancellationState | None = None,
|
|
) -> MCPCallToolResult:
|
|
"""Call a tool using task-augmented execution and poll until completion.
|
|
|
|
This method implements the MCP task workflow:
|
|
1. Creates a task via call_tool_as_task
|
|
2. Polls using poll_task until terminal status (with timeout protection)
|
|
3. Gets the final result using get_task_result
|
|
|
|
Args:
|
|
name: Name of the tool to call.
|
|
arguments: Optional arguments to pass to the tool.
|
|
ttl: Task time-to-live. Uses configured value if not specified.
|
|
poll_timeout: Timeout for polling. Uses configured value if not specified.
|
|
meta: Optional metadata to pass to the tool call per MCP spec (_meta).
|
|
cancellation_state: Internal state used to cancel the exact MCP request or task.
|
|
|
|
Returns:
|
|
MCPCallToolResult: The final tool result after task completion.
|
|
"""
|
|
# Local import to avoid errors on old SDK versions that don't support Tasks
|
|
from mcp.types import ( # type: ignore[attr-defined]
|
|
TASK_STATUS_CANCELLED,
|
|
TASK_STATUS_COMPLETED,
|
|
TASK_STATUS_FAILED,
|
|
GetTaskResult,
|
|
)
|
|
|
|
session = cast(ClientSession, self._background_thread_session)
|
|
|
|
# Precedence: arg > config > default
|
|
timeout = poll_timeout or self._get_task_config().get("poll_timeout", DEFAULT_TASK_POLL_TIMEOUT)
|
|
ttl = ttl or self._get_task_config().get("ttl", DEFAULT_TASK_TTL)
|
|
ttl_ms = int(ttl.total_seconds() * 1000)
|
|
|
|
# Step 1: Create the task
|
|
self._log_debug_with_thread("tool=<%s> | calling tool as task with ttl=%d ms", name, ttl_ms)
|
|
if cancellation_state is not None:
|
|
cancellation_state["session"] = session
|
|
create_task = asyncio.create_task(
|
|
session.experimental.call_tool_as_task( # type: ignore[attr-defined]
|
|
name=name,
|
|
arguments=arguments,
|
|
ttl=ttl_ms,
|
|
meta=meta,
|
|
)
|
|
)
|
|
try:
|
|
create_result = await asyncio.shield(create_task)
|
|
except asyncio.CancelledError:
|
|
done, _ = await asyncio.wait({create_task}, timeout=1)
|
|
if done:
|
|
create_result = create_task.result()
|
|
if cancellation_state is not None:
|
|
cancellation_state["task_id"] = create_result.task.taskId
|
|
await self._cancel_tool_call(cancellation_state)
|
|
else:
|
|
|
|
def cancel_delayed_task(task: asyncio.Task[Any]) -> None:
|
|
if task.cancelled():
|
|
return
|
|
try:
|
|
result = task.result()
|
|
except Exception:
|
|
return
|
|
if cancellation_state is not None:
|
|
cancellation_state["task_id"] = result.task.taskId
|
|
cleanup_task = asyncio.create_task(self._cancel_tool_call(cancellation_state))
|
|
self._track_background_cleanup_task(cleanup_task)
|
|
|
|
self._track_background_cleanup_task(create_task)
|
|
create_task.add_done_callback(cancel_delayed_task)
|
|
raise
|
|
task_id = create_result.task.taskId
|
|
if cancellation_state is not None:
|
|
cancellation_state["task_id"] = task_id
|
|
self._log_debug_with_thread("tool=<%s>, task_id=<%s> | task created", name, task_id)
|
|
|
|
# Step 2: Poll until terminal status (with timeout protection)
|
|
# Note: Using asyncio.wait_for() instead of asyncio.timeout() for Python 3.10 compatibility
|
|
async def _poll_until_terminal() -> GetTaskResult | None:
|
|
"""Inner function to poll task status until terminal state."""
|
|
final = None
|
|
async for task in session.experimental.poll_task(task_id): # type: ignore[attr-defined]
|
|
self._log_debug_with_thread(
|
|
"tool=<%s>, task_id=<%s>, status=<%s> | task status update",
|
|
name,
|
|
task_id,
|
|
task.status,
|
|
)
|
|
final = task
|
|
return final
|
|
|
|
try:
|
|
final_status = await asyncio.wait_for(_poll_until_terminal(), timeout=timeout.total_seconds())
|
|
except asyncio.TimeoutError:
|
|
self._log_debug_with_thread(
|
|
"tool=<%s>, task_id=<%s>, timeout_seconds=<%s> | task polling timed out",
|
|
name,
|
|
task_id,
|
|
timeout.total_seconds(),
|
|
)
|
|
return self._create_task_error_result(
|
|
f"Task {task_id} polling timed out after {timeout.total_seconds()} seconds"
|
|
)
|
|
|
|
# Step 3: Handle terminal status
|
|
if final_status is None:
|
|
self._log_debug_with_thread("tool=<%s>, task_id=<%s> | polling completed without status", name, task_id)
|
|
return self._create_task_error_result(f"Task {task_id} polling completed without status")
|
|
|
|
if final_status.status == TASK_STATUS_FAILED:
|
|
error_msg = final_status.statusMessage or "Task failed" # type: ignore[attr-defined]
|
|
self._log_debug_with_thread("tool=<%s>, task_id=<%s>, error=<%s> | task failed", name, task_id, error_msg)
|
|
return self._create_task_error_result(error_msg)
|
|
|
|
if final_status.status == TASK_STATUS_CANCELLED:
|
|
self._log_debug_with_thread("tool=<%s>, task_id=<%s> | task was cancelled", name, task_id)
|
|
return self._create_task_error_result("Task was cancelled")
|
|
|
|
# Step 4: Get the actual result for completed tasks (with error handling for race conditions)
|
|
if final_status.status == TASK_STATUS_COMPLETED:
|
|
self._log_debug_with_thread("tool=<%s>, task_id=<%s> | task completed, fetching result", name, task_id)
|
|
try:
|
|
experimental_session = session.experimental # type: ignore[attr-defined]
|
|
result = await experimental_session.get_task_result(task_id, MCPCallToolResult)
|
|
self._log_debug_with_thread("tool=<%s>, task_id=<%s> | task result retrieved", name, task_id)
|
|
return result # type: ignore[no-any-return]
|
|
except Exception as e:
|
|
# Handle race condition: task completed but result retrieval failed
|
|
# (e.g., result expired, network error, server restarted)
|
|
self._log_debug_with_thread(
|
|
"tool=<%s>, task_id=<%s>, error=<%s> | failed to retrieve task result", name, task_id, str(e)
|
|
)
|
|
return self._create_task_error_result(f"Task completed but result retrieval failed: {str(e)}")
|
|
|
|
# Unexpected status - return as error
|
|
self._log_debug_with_thread(
|
|
"tool=<%s>, task_id=<%s>, status=<%s> | unexpected task status",
|
|
name,
|
|
task_id,
|
|
final_status.status,
|
|
)
|
|
return self._create_task_error_result(f"Unexpected task status: {final_status.status}")
|
|
|
|
|
|
class _InMemoryTokenStorage:
|
|
"""Process-lifetime OAuth token storage for machine-to-machine credentials.
|
|
|
|
The client_credentials grant re-authenticates with the stored secret whenever the access
|
|
token expires, so tokens only need to live as long as the client instance.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._tokens: OAuthToken | None = None
|
|
self._client_info: OAuthClientInformationFull | None = None
|
|
|
|
async def get_tokens(self) -> OAuthToken | None:
|
|
return self._tokens
|
|
|
|
async def set_tokens(self, tokens: OAuthToken) -> None:
|
|
self._tokens = tokens
|
|
|
|
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
|
return self._client_info
|
|
|
|
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
|
self._client_info = client_info
|
|
|
|
|
|
def _build_client_credentials_auth(url: str, auth: MCPClientCredentials) -> httpx.Auth:
|
|
"""Build an httpx.Auth performing the OAuth client_credentials grant against the server.
|
|
|
|
Error messages name only the offending keys, never their values, so credentials cannot
|
|
leak into logs.
|
|
|
|
Raises:
|
|
ValueError: If `auth` is missing required keys or a value has the wrong type.
|
|
"""
|
|
values = cast(dict[str, Any], auth)
|
|
missing_keys = sorted({"client_id", "client_secret"} - values.keys())
|
|
if missing_keys:
|
|
raise ValueError(f"MCPClient: 'auth' is missing required keys: {missing_keys}")
|
|
if not isinstance(values["client_id"], str) or not isinstance(values["client_secret"], str):
|
|
raise ValueError("MCPClient: 'auth' requires 'client_id' and 'client_secret' to be strings")
|
|
scopes = values.get("scopes")
|
|
if scopes is not None and not (isinstance(scopes, list) and all(isinstance(scope, str) for scope in scopes)):
|
|
raise ValueError("MCPClient: 'auth' requires 'scopes' to be a list of strings")
|
|
return client_credentials_auth(
|
|
server_url=url,
|
|
storage=_InMemoryTokenStorage(),
|
|
client_id=values["client_id"],
|
|
client_secret=values["client_secret"],
|
|
scope=" ".join(scopes) if scopes else None,
|
|
)
|
|
|
|
|
|
def _resolve_transport_callable(
|
|
transport_callable: Callable[[], MCPTransport] | None,
|
|
*,
|
|
url: str | None,
|
|
headers: dict[str, str] | None,
|
|
auth: MCPClientCredentials | None,
|
|
auth_provider: httpx.Auth | None,
|
|
) -> Callable[[], MCPTransport]:
|
|
"""Resolve the MCPClient constructor arguments into a single transport callable.
|
|
|
|
Enforces the constructor invariants: exactly one of `transport_callable` and `url`;
|
|
`headers`, `auth`, and `auth_provider` require `url`; `auth` and `auth_provider` are
|
|
mutually exclusive; `url` must be an http:// or https:// URL.
|
|
"""
|
|
if transport_callable is not None and url:
|
|
raise ValueError("MCPClient: provide either 'transport_callable' or 'url', not both")
|
|
if transport_callable is None and not url:
|
|
raise ValueError("MCPClient: either 'transport_callable' or 'url' must be provided")
|
|
if transport_callable is not None:
|
|
if auth is not None or auth_provider is not None or headers is not None:
|
|
raise ValueError(
|
|
"MCPClient: 'auth', 'auth_provider', and 'headers' require 'url' "
|
|
"(not compatible with 'transport_callable')"
|
|
)
|
|
return transport_callable
|
|
if auth is not None and auth_provider is not None:
|
|
raise ValueError("MCPClient: provide either 'auth' or 'auth_provider', not both")
|
|
|
|
server_url = cast(str, url)
|
|
scheme = urlparse(server_url).scheme
|
|
if scheme not in ("http", "https"):
|
|
raise ValueError(f"MCPClient: 'url' must be an http:// or https:// URL, got '{server_url}'")
|
|
if scheme == "http" and (auth is not None or auth_provider is not None):
|
|
logger.warning("url=<%s> | sending oauth credentials over plaintext http", server_url)
|
|
resolved_auth = _build_client_credentials_auth(server_url, auth) if auth is not None else auth_provider
|
|
return lambda: streamable_http_transport(url=server_url, headers=headers, auth=resolved_auth)
|
|
|
|
|
|
# Matches ${VAR} and ${env:VAR} where VAR is a valid environment variable identifier.
|
|
_ENV_VAR_PATTERN = re.compile(r"\$\{(?:env:)?([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
|
|
|
|
def _load_servers_mapping(config: str | dict[str, Any]) -> dict[str, Any]:
|
|
"""Resolve the load_servers input into a mapping of server name to server config.
|
|
|
|
Reads and parses the file when config is a path, then unwraps the optional ``mcpServers`` key
|
|
so both wrapped and flat shapes are accepted.
|
|
"""
|
|
if isinstance(config, str):
|
|
file_path = config[len("file://") :] if config.startswith("file://") else config
|
|
path = Path(file_path).expanduser()
|
|
if not path.exists():
|
|
raise FileNotFoundError(f"MCP configuration file not found: {file_path}")
|
|
config_dict = json.loads(path.read_text())
|
|
elif isinstance(config, dict):
|
|
config_dict = config
|
|
else:
|
|
raise ValueError("config must be a file path string or a dictionary")
|
|
|
|
servers = config_dict.get("mcpServers", config_dict) if isinstance(config_dict, dict) else None
|
|
if not isinstance(servers, dict):
|
|
raise ValueError(
|
|
'MCP config must be a JSON object mapping server names to configs, e.g. {"my-server": {"command": "node"}}'
|
|
)
|
|
return servers
|
|
|
|
|
|
def _build_client_from_config(
|
|
name: str, server: dict[str, Any], continue_on_error: bool, prefix_with_server_name: bool
|
|
) -> MCPClient:
|
|
"""Build a single MCPClient from one server entry.
|
|
|
|
Interpolates ``${VAR}`` references first so secrets can live in the environment, then detects
|
|
the transport and constructs the matching transport callable.
|
|
"""
|
|
server = cast(dict[str, Any], _interpolate_env_vars(server))
|
|
|
|
if server.get("command") and server.get("url") and not server.get("transport"):
|
|
raise ValueError(f"server '{name}' has both 'command' and 'url' — set 'transport' explicitly or remove one")
|
|
|
|
transport = server.get("transport")
|
|
if transport is None:
|
|
transport = "stdio" if server.get("command") else "streamable-http" if server.get("url") else None
|
|
if transport is None:
|
|
raise ValueError(f"server '{name}' must include either 'command' (stdio) or 'url' (http)")
|
|
|
|
transport_callable = _config_transport_callable(name, transport, server)
|
|
|
|
logger.debug("server_name=<%s>, transport=<%s> | creating MCP client from config", name, transport)
|
|
return MCPClient(
|
|
transport_callable,
|
|
startup_timeout=server.get("startup_timeout", 30),
|
|
tool_filters=_parse_config_tool_filters(name, server.get("tool_filters")),
|
|
prefix=server.get("prefix", _sanitize_prefix(name) if prefix_with_server_name else None),
|
|
application_name=server.get("application_name", name),
|
|
application_version=server.get("application_version"),
|
|
continue_on_error=server.get("continue_on_error", continue_on_error),
|
|
)
|
|
|
|
|
|
def _sanitize_prefix(name: str) -> str:
|
|
"""Replace characters that model providers reject in tool names (e.g. the dot in ``awslabs.foo``) with ``_``."""
|
|
return re.sub(r"[^A-Za-z0-9_-]", "_", name)
|
|
|
|
|
|
def _config_transport_callable(name: str, transport: str, server: dict[str, Any]) -> Callable[[], MCPTransport]:
|
|
"""Return a zero-arg callable that opens the transport for the given server entry."""
|
|
if transport != "streamable-http" and server.get("auth"):
|
|
raise ValueError(
|
|
f"server '{name}': Strands supports 'auth' for streamable-http transport only — "
|
|
"use streamable-http or provide a pre-configured transport"
|
|
)
|
|
|
|
match transport:
|
|
case "stdio":
|
|
command = server.get("command")
|
|
if not command:
|
|
raise ValueError(f"server '{name}': stdio transport requires 'command'")
|
|
params = StdioServerParameters(
|
|
command=os.path.expanduser(command),
|
|
args=server.get("args", []),
|
|
env=server.get("env"),
|
|
cwd=os.path.expanduser(server["cwd"]) if server.get("cwd") else None,
|
|
)
|
|
return lambda: stdio_client(params) # type: ignore[return-value]
|
|
|
|
case "streamable-http":
|
|
url = server.get("url")
|
|
if not url:
|
|
raise ValueError(f"server '{name}': streamable-http transport requires 'url'")
|
|
headers = server.get("headers")
|
|
resolved_auth = _parse_config_auth(name, cast(str, url), server.get("auth"))
|
|
return lambda: streamable_http_transport(url=cast(str, url), headers=headers, auth=resolved_auth)
|
|
|
|
case "sse":
|
|
url = server.get("url")
|
|
if not url:
|
|
raise ValueError(f"server '{name}': sse transport requires 'url'")
|
|
headers = server.get("headers")
|
|
return lambda: sse_client(url=cast(str, url), headers=headers)
|
|
|
|
case _:
|
|
raise ValueError(f"server '{name}': unsupported transport type '{transport}'")
|
|
|
|
|
|
def _parse_config_auth(name: str, url: str, auth: dict[str, Any] | None) -> httpx.Auth | None:
|
|
"""Validate a config 'auth' entry and build the client_credentials httpx.Auth from it."""
|
|
if auth is None:
|
|
return None
|
|
if not isinstance(auth, dict):
|
|
raise ValueError(f"server '{name}': 'auth' must be an object with 'client_id' and 'client_secret'")
|
|
|
|
missing_keys = sorted({"client_id", "client_secret"} - auth.keys())
|
|
if missing_keys:
|
|
raise ValueError(f"server '{name}': 'auth' is missing required keys: {missing_keys}")
|
|
unknown_keys = sorted(auth.keys() - MCPClientCredentials.__annotations__.keys())
|
|
if unknown_keys:
|
|
logger.warning("server_name=<%s>, unknown_keys=<%s> | ignoring unrecognized auth keys", name, unknown_keys)
|
|
|
|
credentials = MCPClientCredentials(client_id=auth["client_id"], client_secret=auth["client_secret"])
|
|
if auth.get("scopes") is not None:
|
|
credentials["scopes"] = auth["scopes"]
|
|
return _build_client_credentials_auth(url, credentials)
|
|
|
|
|
|
def _parse_config_tool_filters(name: str, config: dict[str, Any] | None) -> ToolFilters | None:
|
|
"""Compile a tool-filter config into a ToolFilters mapping of regex patterns.
|
|
|
|
Patterns match the server-side (unprefixed) tool name; any ``prefix`` is applied afterwards.
|
|
"""
|
|
if not config:
|
|
return None
|
|
|
|
result: ToolFilters = {}
|
|
if config.get("allowed") is not None:
|
|
result["allowed"] = _compile_filter_patterns(name, "allowed", config["allowed"])
|
|
if config.get("rejected") is not None:
|
|
result["rejected"] = _compile_filter_patterns(name, "rejected", config["rejected"])
|
|
|
|
return result or None
|
|
|
|
|
|
def _compile_filter_patterns(name: str, key: str, patterns: list[str]) -> list[_ToolMatcher]:
|
|
"""Compile a list of regex strings into patterns, raising a clear error on an invalid one."""
|
|
compiled: list[_ToolMatcher] = []
|
|
for pattern in patterns:
|
|
try:
|
|
compiled.append(re.compile(pattern))
|
|
except re.error as e:
|
|
raise ValueError(f"server '{name}': invalid regex in tool_filters.{key}: '{pattern}': {e}") from e
|
|
return compiled
|
|
|
|
|
|
def _interpolate_env_vars(value: Any) -> Any:
|
|
"""Recursively replace ``${VAR}`` / ``${env:VAR}`` references in strings with env values.
|
|
|
|
Strings nested inside lists and dicts are interpolated; other types pass through unchanged.
|
|
|
|
Raises:
|
|
ValueError: If a referenced environment variable is not set.
|
|
"""
|
|
if isinstance(value, str):
|
|
|
|
def _replace(match: re.Match[str]) -> str:
|
|
var = match.group(1)
|
|
if var not in os.environ:
|
|
raise ValueError(f"environment variable '{var}' is not set")
|
|
return os.environ[var]
|
|
|
|
return _ENV_VAR_PATTERN.sub(_replace, value)
|
|
if isinstance(value, list):
|
|
return [_interpolate_env_vars(item) for item in value]
|
|
if isinstance(value, dict):
|
|
return {key: _interpolate_env_vars(item) for key, item in value.items()}
|
|
return value
|