From fff38643e56cfc6a22d14d9928ff224d08b45da7 Mon Sep 17 00:00:00 2001 From: Thiago Almeida Date: Mon, 15 Jun 2026 14:59:06 -0700 Subject: [PATCH 1/2] fix(client_manager): add explicit HTTP timeouts to Foundry chat client Fixes #65 Configure explicit connect/read HTTP timeouts on the Azure SDK transport used by the Foundry chat client, so transient Foundry latency surfaces as a raised exception instead of an indefinite worker-process hang. Defaults: read=180s, connect=10s. Both overridable via FOUNDRY_HTTP_READ_TIMEOUT and FOUNDRY_HTTP_CONNECT_TIMEOUT app settings. --- src/azure_functions_agents/client_manager.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/azure_functions_agents/client_manager.py b/src/azure_functions_agents/client_manager.py index 95bb914f..be8756d5 100644 --- a/src/azure_functions_agents/client_manager.py +++ b/src/azure_functions_agents/client_manager.py @@ -188,6 +188,8 @@ def _build_azure_openai(cls, model: str) -> Any: @classmethod def _build_foundry(cls, model: str) -> Any: from agent_framework.foundry import FoundryChatClient + from azure.ai.projects.aio import AIProjectClient + from azure.core.pipeline.transport import AioHttpTransport endpoint = cls._env("FOUNDRY_PROJECT_ENDPOINT") if not endpoint: @@ -195,10 +197,24 @@ def _build_foundry(cls, model: str) -> Any: "AZURE_FUNCTIONS_AGENTS_PROVIDER=foundry requires " "FOUNDRY_PROJECT_ENDPOINT to be set." ) + # Configure explicit HTTP timeouts on the underlying Azure SDK transport + # so transient Foundry latency surfaces as a raised exception instead of + # an indefinite hang on receive_response_headers.started. + # See: https://github.com/Azure/azure-functions-agents-runtime/issues/65 + read_timeout = float(cls._env("FOUNDRY_HTTP_READ_TIMEOUT") or 180) + connect_timeout = float(cls._env("FOUNDRY_HTTP_CONNECT_TIMEOUT") or 10) + transport = AioHttpTransport( + connection_timeout=connect_timeout, + read_timeout=read_timeout, + ) + project_client = AIProjectClient( + endpoint=endpoint, + credential=build_async_credential(), + transport=transport, + ) return FoundryChatClient( - project_endpoint=endpoint, + project_client=project_client, model=model, - credential=build_async_credential(), ) From 852c9ca109fe8ed32126a0df7fde5ca87dcb7d7d Mon Sep 17 00:00:00 2001 From: Thiago Almeida Date: Tue, 16 Jun 2026 13:43:12 -0700 Subject: [PATCH 2/2] fix(mcp): apply HTTP timeouts to MCP tool transport and request lifecycle The MCP server discovery code creates httpx.AsyncClient instances with no timeout, and never passes request_timeout to MCPStreamableHTTPTool. When an MCP connector (e.g., Kusto) stalls, the agent hangs indefinitely. This change: - Reads the per-server `timeout` config from mcp.json (connect, read, write, pool) and applies it to the httpx transport - Passes `request_timeout` to MCPStreamableHTTPTool for end-to-end tool call timeout - Defaults to 120s when no timeout is configured - Supports MCP_REQUEST_TIMEOUT env var as a global override Complements the Foundry client timeout fix in client_manager.py. Refs: #65 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/azure_functions_agents/discovery/mcp.py | 63 ++++++++++++++++----- 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/src/azure_functions_agents/discovery/mcp.py b/src/azure_functions_agents/discovery/mcp.py index 3dcbab70..d6211f89 100644 --- a/src/azure_functions_agents/discovery/mcp.py +++ b/src/azure_functions_agents/discovery/mcp.py @@ -4,6 +4,7 @@ import asyncio import json +import os import time from pathlib import Path from typing import Any, cast @@ -76,18 +77,37 @@ def default_credential_header_provider(_ctx: Any) -> dict[str, str]: return default_credential_header_provider -def _build_http_client(header_provider: Any) -> Any: - if header_provider is None: - return None +def _build_http_client( + header_provider: Any, timeout_config: dict[str, Any] | int | float | None = None +) -> Any: + from httpx import AsyncClient, Timeout - from httpx import AsyncClient + default_timeout = float(os.environ.get("MCP_REQUEST_TIMEOUT") or 120) + if isinstance(timeout_config, dict): + timeout = Timeout( + connect=float(timeout_config.get("connect", 10)), + read=float(timeout_config.get("read", default_timeout)), + write=float(timeout_config.get("write", 30)), + pool=float(timeout_config.get("pool", default_timeout)), + ) + elif isinstance(timeout_config, (int, float)): + timeout = Timeout(float(timeout_config)) + else: + timeout = Timeout(default_timeout) + + if header_provider is None: + return AsyncClient(follow_redirects=True, timeout=timeout) async def inject_headers(request: Any) -> None: headers = await asyncio.to_thread(header_provider, {}) for key, value in headers.items(): request.headers[key] = value - return AsyncClient(follow_redirects=True, event_hooks={"request": [inject_headers]}) + return AsyncClient( + follow_redirects=True, + timeout=timeout, + event_hooks={"request": [inject_headers]}, + ) def _build_mcp_tool(name: str, server: dict[str, Any]) -> MCPTool | None: @@ -119,17 +139,30 @@ def _build_mcp_tool(name: str, server: dict[str, Any]) -> MCPTool | None: if has_unresolved_placeholders(url): logger.warning("MCP server '%s': could not resolve url '%s', skipping", name, url) return None + timeout_config = server.get("timeout") + default_timeout = float(os.environ.get("MCP_REQUEST_TIMEOUT") or 120) + if isinstance(timeout_config, dict): + request_timeout = float(timeout_config.get("read", default_timeout)) + elif isinstance(timeout_config, (int, float)): + request_timeout = float(timeout_config) + else: + request_timeout = default_timeout + timeout_log_value: float | str = request_timeout if timeout_config is not None else "default" + logger.info("MCP server '%s': timeout configured (read=%ss)", name, timeout_log_value) header_provider = _build_header_provider(server) - - return MCPStreamableHTTPTool( - name=name, - url=url, - allowed_tools=allowed_tools, - load_tools=True, - load_prompts=False, - header_provider=header_provider, - http_client=_build_http_client(header_provider), - ) + http_client = _build_http_client(header_provider, timeout_config) + tool_kwargs: dict[str, Any] = { + "name": name, + "url": url, + "allowed_tools": allowed_tools, + "load_tools": True, + "load_prompts": False, + "header_provider": header_provider, + "http_client": http_client, + "request_timeout": request_timeout, + } + + return MCPStreamableHTTPTool(**tool_kwargs) if server_type: logger.warning(