From 722d5046fc7624f2a9714fd77fb0c3582aa86047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Verdier?= Date: Mon, 14 Sep 2026 18:11:51 +0200 Subject: [PATCH 1/2] feat(connectors): add direct gateway calls Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- .../client/_hooks/connector_deprecation.py | 20 ++ src/mistralai/client/_hooks/registration.py | 2 + src/mistralai/client/connectors.py | 59 ++++ src/mistralai/extra/connectors_gateway.py | 233 +++++++++++++++ .../extra/tests/test_connectors_gateway.py | 275 ++++++++++++++++++ 5 files changed, 589 insertions(+) create mode 100644 src/mistralai/client/_hooks/connector_deprecation.py create mode 100644 src/mistralai/extra/connectors_gateway.py create mode 100644 src/mistralai/extra/tests/test_connectors_gateway.py diff --git a/src/mistralai/client/_hooks/connector_deprecation.py b/src/mistralai/client/_hooks/connector_deprecation.py new file mode 100644 index 00000000..eb48f1b1 --- /dev/null +++ b/src/mistralai/client/_hooks/connector_deprecation.py @@ -0,0 +1,20 @@ +import warnings +from typing import Union + +import httpx + +from .types import BeforeRequestContext, BeforeRequestHook + + +class ConnectorToolDeprecationHook(BeforeRequestHook): + def before_request( + self, hook_ctx: BeforeRequestContext, request: httpx.Request + ) -> Union[httpx.Request, Exception]: + if hook_ctx.operation_id == "connector_call_tool_v1": + warnings.warn( + "call_tool and call_tool_async are deprecated; use " + "call_mcp_tool_async to call the connectors gateway directly.", + DeprecationWarning, + stacklevel=5, + ) + return request diff --git a/src/mistralai/client/_hooks/registration.py b/src/mistralai/client/_hooks/registration.py index f262aee1..26213814 100644 --- a/src/mistralai/client/_hooks/registration.py +++ b/src/mistralai/client/_hooks/registration.py @@ -1,3 +1,4 @@ +from .connector_deprecation import ConnectorToolDeprecationHook from .custom_user_agent import CustomUserAgentHook from .deprecation_warning import DeprecationWarningHook from .traceparent import TraceparentInjectionHook @@ -20,6 +21,7 @@ def init_hooks(hooks: Hooks): tracing_hook = TracingHook() workflow_encoding_hook = WorkflowEncodingHook() hooks.register_before_request_hook(CustomUserAgentHook()) + hooks.register_before_request_hook(ConnectorToolDeprecationHook()) hooks.register_before_request_hook(TraceparentInjectionHook()) hooks.register_after_success_hook(DeprecationWarningHook()) hooks.register_after_success_hook(tracing_hook) diff --git a/src/mistralai/client/connectors.py b/src/mistralai/client/connectors.py index cab86f09..5699e517 100644 --- a/src/mistralai/client/connectors.py +++ b/src/mistralai/client/connectors.py @@ -9,10 +9,69 @@ from mistralai.client.utils.unmarshal_json_response import unmarshal_json_response from typing import Any, Dict, List, Mapping, Optional, Union, cast +# region imports +import httpx + +from mistralai.extra.connectors_gateway import ( + call_http_endpoint_async as call_http_endpoint_via_gateway_async, + call_mcp_tool_async as call_mcp_tool_via_gateway_async, +) +# endregion imports + class Connectors(BaseSDK): r"""(beta) Connectors API - manage your connectors""" + # region sdk-class-body + async def call_mcp_tool_async( + self, + *, + connector_id_or_name: str, + tool_name: str, + arguments: Optional[Mapping[str, Any]] = None, + credentials_name: Optional[str] = None, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + ) -> models.ConnectorToolCallResponse: + r"""Call an MCP connector tool directly through the stateless connectors gateway. + + This is the async replacement for ``call_tool`` and ``call_tool_async``. + """ + return await call_mcp_tool_via_gateway_async( + self.sdk_configuration, + connector_id_or_name=connector_id_or_name, + tool_name=tool_name, + arguments=arguments, + credentials_name=credentials_name, + server_url=server_url, + timeout_ms=timeout_ms, + ) + + async def call_http_endpoint_async( + self, + *, + connector_id_or_name: str, + request: httpx.Request, + credentials_name: Optional[str] = None, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + ) -> httpx.Response: + r"""Call an HTTP connector endpoint directly through the connectors gateway. + + The supplied request must have a relative URL and is consumed by this call. + The raw HTTP response is returned without raising for upstream status codes. + """ + return await call_http_endpoint_via_gateway_async( + self.sdk_configuration, + connector_id_or_name=connector_id_or_name, + request=request, + credentials_name=credentials_name, + server_url=server_url, + timeout_ms=timeout_ms, + ) + + # endregion sdk-class-body + def create( self, *, diff --git a/src/mistralai/extra/connectors_gateway.py b/src/mistralai/extra/connectors_gateway.py new file mode 100644 index 00000000..eaf03355 --- /dev/null +++ b/src/mistralai/extra/connectors_gateway.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +from collections.abc import AsyncIterable +from typing import Any, Mapping +from urllib.parse import quote, unquote, urlsplit + +import httpx + +from mistralai.client import errors, models, utils +from mistralai.client.sdkconfiguration import SDKConfiguration +from mistralai.extra.exceptions import MCPException + +_DEFAULT_TIMEOUT_MS = 300_000 +_GATEWAY_PATH = "/v1/connectors-gateway" + + +class ConnectorGatewayProtocolError(MCPException): + """Raised when the connectors gateway returns an invalid JSON-RPC response.""" + + def __init__( + self, + message: str, + *, + code: int | None = None, + data: Any = None, + ) -> None: + self.code = code + self.data = data + super().__init__(message) + + +def _request_headers( + sdk_configuration: SDKConfiguration, + *, + credentials_name: str | None, + headers: httpx.Headers | None = None, +) -> httpx.Headers: + security_source = sdk_configuration.security + if callable(security_source): + security_source = security_source() + security = utils.get_security_from_env(security_source, models.Security) + security_headers, _ = utils.get_security(security) + + request_headers = httpx.Headers( + { + "user-agent": sdk_configuration.user_agent, + **security_headers, + } + ) + if headers is not None: + request_headers.update(headers) + if credentials_name is not None: + request_headers["x-credentials-name"] = credentials_name + return request_headers + + +def _gateway_url( + sdk_configuration: SDKConfiguration, + connector_id_or_name: str, + suffix: str, + *, + server_url: str | None, +) -> httpx.URL: + if server_url is None: + server_url, _ = sdk_configuration.get_server_details() + connector_ref = quote(connector_id_or_name, safe="") + return httpx.URL( + f"{server_url.rstrip('/')}{_GATEWAY_PATH}/{connector_ref}/{suffix.lstrip('/')}" + ) + + +def _timeout(sdk_configuration: SDKConfiguration, timeout_ms: int | None) -> float: + effective_timeout_ms = timeout_ms + if effective_timeout_ms is None: + effective_timeout_ms = sdk_configuration.timeout_ms + if effective_timeout_ms is None: + effective_timeout_ms = _DEFAULT_TIMEOUT_MS + return effective_timeout_ms / 1000 + + +def _parse_tool_result(response: httpx.Response) -> models.ConnectorToolCallResponse: + if response.status_code != 200: + raise errors.SDKError("Connector gateway error", response) + + try: + envelope = response.json() + except ValueError as exc: + raise ConnectorGatewayProtocolError( + "Connector gateway returned a non-JSON MCP response" + ) from exc + + if ( + not isinstance(envelope, dict) + or envelope.get("jsonrpc") != "2.0" + or envelope.get("id") != 1 + ): + raise ConnectorGatewayProtocolError( + "Connector gateway returned an invalid JSON-RPC response" + ) + + error = envelope.get("error") + if isinstance(error, dict): + message = error.get("message", "Connector tool call failed") + code = error.get("code") + raise ConnectorGatewayProtocolError( + str(message), + code=code if isinstance(code, int) else None, + data=error.get("data"), + ) + + result = envelope.get("result") + if not isinstance(result, dict) or not isinstance(result.get("content"), list): + raise ConnectorGatewayProtocolError( + "Connector gateway response is missing an MCP tool result" + ) + + response_data: dict[str, Any] = {"content": result["content"]} + if ( + result.get("isError", False) + or result.get("structuredContent") is not None + or result.get("_meta") is not None + ): + response_data["metadata"] = { + "mcp_meta": { + "isError": result.get("isError", False), + "structuredContent": result.get("structuredContent"), + "_meta": result.get("_meta"), + } + } + return models.ConnectorToolCallResponse.model_validate(response_data) + + +async def call_mcp_tool_async( + sdk_configuration: SDKConfiguration, + *, + connector_id_or_name: str, + tool_name: str, + arguments: Mapping[str, Any] | None = None, + credentials_name: str | None = None, + server_url: str | None = None, + timeout_ms: int | None = None, +) -> models.ConnectorToolCallResponse: + """Call an MCP connector tool directly through the stateless gateway.""" + client = sdk_configuration.async_client + if client is None: + raise ValueError("async client is required") + + request = client.build_request( + "POST", + _gateway_url( + sdk_configuration, + connector_id_or_name, + "mcp", + server_url=server_url, + ), + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": dict(arguments or {}), + }, + }, + headers=_request_headers( + sdk_configuration, + credentials_name=credentials_name, + headers=httpx.Headers({"accept": "application/json, text/event-stream"}), + ), + timeout=_timeout(sdk_configuration, timeout_ms), + ) + response = await client.send(request, auth=None, follow_redirects=False) + return _parse_tool_result(response) + + +async def call_http_endpoint_async( + sdk_configuration: SDKConfiguration, + *, + connector_id_or_name: str, + request: httpx.Request, + credentials_name: str | None = None, + server_url: str | None = None, + timeout_ms: int | None = None, +) -> httpx.Response: + """Send a relative HTTP request through an HTTP connector gateway route.""" + target = urlsplit(str(request.url)) + if target.scheme or target.netloc: + raise ValueError("HTTP connector request URL must be relative") + if any(unquote(segment) in {".", ".."} for segment in target.path.split("/")): + raise ValueError("HTTP connector request URL must not contain dot segments") + + client = sdk_configuration.async_client + if client is None: + raise ValueError("async client is required") + + try: + body = request.content + except httpx.RequestNotRead: + if isinstance(request.stream, AsyncIterable): + body = await request.aread() + else: + body = request.read() + + path = target.path.lstrip("/") + gateway_url = _gateway_url( + sdk_configuration, + connector_id_or_name, + f"http/{path}", + server_url=server_url, + ) + if target.query: + gateway_url = gateway_url.copy_with(query=target.query.encode("ascii")) + + has_explicit_body = ( + bool(body) + or "content-length" in request.headers + or "transfer-encoding" in request.headers + ) + extensions = dict(request.extensions) + extensions.pop("timeout", None) + gateway_request = client.build_request( + request.method, + gateway_url, + content=body if has_explicit_body else None, + headers=_request_headers( + sdk_configuration, + credentials_name=credentials_name, + headers=request.headers, + ), + timeout=_timeout(sdk_configuration, timeout_ms), + extensions=extensions, + ) + return await client.send(gateway_request, auth=None, follow_redirects=False) diff --git a/src/mistralai/extra/tests/test_connectors_gateway.py b/src/mistralai/extra/tests/test_connectors_gateway.py new file mode 100644 index 00000000..ad131ed7 --- /dev/null +++ b/src/mistralai/extra/tests/test_connectors_gateway.py @@ -0,0 +1,275 @@ +import json +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager +from typing import Any + +import httpx +import pytest + +from mistralai.client import Mistral, errors, models +from mistralai.extra.connectors_gateway import ConnectorGatewayProtocolError + + +@asynccontextmanager +async def _mistral_client( + handler: Callable[[httpx.Request], Any], + *, + auth: Any = None, +) -> AsyncIterator[Mistral]: + async_client = httpx.AsyncClient( + transport=httpx.MockTransport(handler), + auth=auth, + ) + sync_client = httpx.Client( + transport=httpx.MockTransport( + lambda request: httpx.Response(500, request=request) + ) + ) + try: + yield Mistral( + api_key="test-api-key", + server_url="https://api.example.test", + client=sync_client, + async_client=async_client, + ) + finally: + await async_client.aclose() + sync_client.close() + + +@pytest.mark.asyncio +async def test_legacy_tool_call_async_is_deprecated() -> None: + async def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"content": []}) + + async with _mistral_client(handler) as mistral: + with pytest.warns(DeprecationWarning, match="call_mcp_tool_async"): + await mistral.beta.connectors.call_tool_async( + connector_id_or_name="github", + tool_name="search", + ) + + +@pytest.mark.asyncio +async def test_call_mcp_tool_async_calls_gateway_and_converts_result() -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": 1, + "result": { + "content": [{"type": "text", "text": "done"}], + "isError": True, + "structuredContent": {"answer": 42}, + "_meta": {"source": "test"}, + }, + }, + ) + + async with _mistral_client(handler) as mistral: + result = await mistral.beta.connectors.call_mcp_tool_async( + connector_id_or_name="my/connector", + tool_name="search", + arguments={"query": "mistral"}, + credentials_name="work", + ) + await mistral.beta.connectors.call_mcp_tool_async( + connector_id_or_name="my/connector", + tool_name="search", + ) + + assert mistral.sdk_configuration.async_client is not None + + assert len(requests) == 2 + request = requests[0] + assert request.url.raw_path == b"/v1/connectors-gateway/my%2Fconnector/mcp" + assert request.headers["authorization"] == "Bearer test-api-key" + assert request.headers["x-credentials-name"] == "work" + assert request.headers["accept"] == "application/json, text/event-stream" + assert json.loads(request.content) == { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "search", "arguments": {"query": "mistral"}}, + } + assert result.content[0].type == "text" + assert result.content[0].text == "done" + assert isinstance(result.metadata, models.ConnectorToolCallMetadata) + assert isinstance(result.metadata.mcp_meta, models.ConnectorToolResultMetadata) + assert result.metadata.mcp_meta.is_error is True + assert result.metadata.mcp_meta.structured_content == {"answer": 42} + assert result.metadata.mcp_meta.meta == {"source": "test"} + + +@pytest.mark.asyncio +async def test_call_mcp_tool_async_raises_sdk_error_for_http_error() -> None: + async def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(401, json={"detail": "Unauthorized"}) + + async with _mistral_client(handler) as mistral: + with pytest.raises(errors.SDKError) as exc_info: + await mistral.beta.connectors.call_mcp_tool_async( + connector_id_or_name="github", + tool_name="search", + ) + + assert exc_info.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_call_mcp_tool_async_raises_protocol_error() -> None: + async def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": 1, + "error": {"code": -32602, "message": "Invalid arguments"}, + }, + ) + + async with _mistral_client(handler) as mistral: + with pytest.raises(ConnectorGatewayProtocolError) as exc_info: + await mistral.beta.connectors.call_mcp_tool_async( + connector_id_or_name="github", + tool_name="search", + ) + + assert exc_info.value.code == -32602 + assert str(exc_info.value) == "Invalid arguments" + + +@pytest.mark.asyncio +async def test_call_http_endpoint_async_proxies_relative_request() -> None: + captured: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response( + 418, + content=b"upstream response", + headers=[("set-cookie", "a=1"), ("set-cookie", "b=2")], + ) + + connector_request = httpx.Request( + "POST", + "/items/search?q=hello%20world", + headers={ + "authorization": "Bearer caller-value", + "content-type": "application/json", + "x-upstream-header": "value", + }, + json={"limit": 10}, + ) + + async with _mistral_client( + handler, + auth=httpx.BasicAuth("client", "password"), + ) as mistral: + response = await mistral.beta.connectors.call_http_endpoint_async( + connector_id_or_name="http connector", + request=connector_request, + credentials_name="secondary", + timeout_ms=1_234, + ) + + assert response.status_code == 418 + assert response.content == b"upstream response" + assert response.headers.get_list("set-cookie") == ["a=1", "b=2"] + + request = captured[0] + assert request.url.raw_path == ( + b"/v1/connectors-gateway/http%20connector/http/items/search?q=hello%20world" + ) + assert request.headers["authorization"] == "Bearer caller-value" + assert request.headers["x-credentials-name"] == "secondary" + assert request.headers["x-upstream-header"] == "value" + assert request.extensions["timeout"]["read"] == 1.234 + assert json.loads(request.content) == {"limit": 10} + + +@pytest.mark.asyncio +async def test_call_http_endpoint_async_does_not_follow_redirects() -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(302, headers={"location": "/next"}) + + async with _mistral_client(handler) as mistral: + response = await mistral.beta.connectors.call_http_endpoint_async( + connector_id_or_name="github", + request=httpx.Request("GET", "/start"), + ) + + assert response.status_code == 302 + assert len(requests) == 1 + + +@pytest.mark.asyncio +async def test_call_http_endpoint_async_supports_sync_streaming_request() -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200) + + connector_request = httpx.Request( + "POST", + "/upload", + content=iter([b"hello", b" world"]), + ) + async with _mistral_client(handler) as mistral: + await mistral.beta.connectors.call_http_endpoint_async( + connector_id_or_name="files", + request=connector_request, + ) + + assert requests[0].content == b"hello world" + + +@pytest.mark.asyncio +async def test_call_http_endpoint_async_rejects_absolute_url() -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200) + + async with _mistral_client(handler) as mistral: + with pytest.raises( + ValueError, + match="HTTP connector request URL must be relative", + ): + await mistral.beta.connectors.call_http_endpoint_async( + connector_id_or_name="github", + request=httpx.Request("GET", "https://api.github.com/user"), + ) + + assert requests == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("url", ["../models", "/items/%2e%2e/models"]) +async def test_call_http_endpoint_async_rejects_dot_segments(url: str) -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200) + + async with _mistral_client(handler) as mistral: + with pytest.raises( + ValueError, + match="HTTP connector request URL must not contain dot segments", + ): + await mistral.beta.connectors.call_http_endpoint_async( + connector_id_or_name="github", + request=httpx.Request("GET", url), + ) + + assert requests == [] From 939194a6746a88c87a5be7c8f78fd25be815ac93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Verdier?= Date: Tue, 15 Sep 2026 11:14:49 +0200 Subject: [PATCH 2/2] refactor(connectors): defer legacy deprecation to spec Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- .../client/_hooks/connector_deprecation.py | 20 ------------------- src/mistralai/client/_hooks/registration.py | 2 -- .../extra/tests/test_connectors_gateway.py | 13 ------------ 3 files changed, 35 deletions(-) delete mode 100644 src/mistralai/client/_hooks/connector_deprecation.py diff --git a/src/mistralai/client/_hooks/connector_deprecation.py b/src/mistralai/client/_hooks/connector_deprecation.py deleted file mode 100644 index eb48f1b1..00000000 --- a/src/mistralai/client/_hooks/connector_deprecation.py +++ /dev/null @@ -1,20 +0,0 @@ -import warnings -from typing import Union - -import httpx - -from .types import BeforeRequestContext, BeforeRequestHook - - -class ConnectorToolDeprecationHook(BeforeRequestHook): - def before_request( - self, hook_ctx: BeforeRequestContext, request: httpx.Request - ) -> Union[httpx.Request, Exception]: - if hook_ctx.operation_id == "connector_call_tool_v1": - warnings.warn( - "call_tool and call_tool_async are deprecated; use " - "call_mcp_tool_async to call the connectors gateway directly.", - DeprecationWarning, - stacklevel=5, - ) - return request diff --git a/src/mistralai/client/_hooks/registration.py b/src/mistralai/client/_hooks/registration.py index 26213814..f262aee1 100644 --- a/src/mistralai/client/_hooks/registration.py +++ b/src/mistralai/client/_hooks/registration.py @@ -1,4 +1,3 @@ -from .connector_deprecation import ConnectorToolDeprecationHook from .custom_user_agent import CustomUserAgentHook from .deprecation_warning import DeprecationWarningHook from .traceparent import TraceparentInjectionHook @@ -21,7 +20,6 @@ def init_hooks(hooks: Hooks): tracing_hook = TracingHook() workflow_encoding_hook = WorkflowEncodingHook() hooks.register_before_request_hook(CustomUserAgentHook()) - hooks.register_before_request_hook(ConnectorToolDeprecationHook()) hooks.register_before_request_hook(TraceparentInjectionHook()) hooks.register_after_success_hook(DeprecationWarningHook()) hooks.register_after_success_hook(tracing_hook) diff --git a/src/mistralai/extra/tests/test_connectors_gateway.py b/src/mistralai/extra/tests/test_connectors_gateway.py index ad131ed7..3766e416 100644 --- a/src/mistralai/extra/tests/test_connectors_gateway.py +++ b/src/mistralai/extra/tests/test_connectors_gateway.py @@ -37,19 +37,6 @@ async def _mistral_client( sync_client.close() -@pytest.mark.asyncio -async def test_legacy_tool_call_async_is_deprecated() -> None: - async def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"content": []}) - - async with _mistral_client(handler) as mistral: - with pytest.warns(DeprecationWarning, match="call_mcp_tool_async"): - await mistral.beta.connectors.call_tool_async( - connector_id_or_name="github", - tool_name="search", - ) - - @pytest.mark.asyncio async def test_call_mcp_tool_async_calls_gateway_and_converts_result() -> None: requests: list[httpx.Request] = []