From 155dc958e00f1d55857ad98f11d4344128e7017b Mon Sep 17 00:00:00 2001 From: Elvis Pranskevichus Date: Sun, 17 May 2026 14:42:53 -0700 Subject: [PATCH] Make mcp an optional dependency The mcp package dependency is now optional, imported lazily only when an mcp client is used. While here, bump `mcp` to latest. --- pyproject.toml | 3 +- src/ai/agents/mcp/client.py | 63 +++++++++++++++++++++++---------- tests/agents/mcp/test_client.py | 20 +++++++++++ uv.lock | 16 +++++---- 4 files changed, 77 insertions(+), 25 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fe456e48..22410561 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,6 @@ classifiers = [ requires-python = ">=3.12" dependencies = [ "httpx>=0.28.1", - "mcp>=1.18.0", "modelsdotdev==0.*", "pydantic>=2.12.5", "typing-extensions>=4.15.0", @@ -37,6 +36,7 @@ dependencies = [ [project.optional-dependencies] anthropic = ["anthropic>=0.83.0"] +mcp = ["mcp>=1.18.0"] openai = ["openai>=2.14.0"] [build-system] @@ -54,6 +54,7 @@ bump = true [dependency-groups] dev = [ "anthropic>=0.83.0", + "mcp>=1.18.0", "python-dotenv>=1.2.1", "pytest>=8.0", "pytest-asyncio>=0.24", diff --git a/src/ai/agents/mcp/client.py b/src/ai/agents/mcp/client.py index cd2b2695..38e6b4a4 100644 --- a/src/ai/agents/mcp/client.py +++ b/src/ai/agents/mcp/client.py @@ -4,8 +4,9 @@ import contextlib import contextvars import dataclasses +import importlib import json -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast if TYPE_CHECKING: from collections.abc import AsyncIterator, Awaitable, Callable @@ -13,7 +14,7 @@ import mcp.client.session import mcp.types -from ... import types +from ... import errors, types from ..agent import AgentTool, Tool __all__ = [ @@ -39,6 +40,21 @@ class _Connection: _pool_lock = asyncio.Lock() +def _import_mcp_module(module_name: str) -> Any: + """Import an MCP module or raise the public optional dependency error.""" + try: + return importlib.import_module(module_name) + except ModuleNotFoundError as exc: + root_module = module_name.partition(".")[0] + if exc.name not in {module_name, root_module}: + raise + raise errors.InstallationError( + "could not import `mcp`, which is required to use MCP tools, " + 'you can install it with `pip install "ai[mcp]"` or ' + '`uv add "ai[mcp]"`' + ) from exc + + @contextlib.asynccontextmanager async def ensure_connection_pool() -> AsyncIterator[dict[str, _Connection]]: pool = orig_pool = _pool.get() @@ -60,7 +76,10 @@ async def _get_or_create_connection( ], ) -> mcp.client.session.ClientSession: """Get an existing connection or create a new one.""" - import mcp.client.session as _mcp_session # noqa: PLC0415 + mcp_session = _import_mcp_module("mcp.client.session") + client_session = cast( + "type[mcp.client.session.ClientSession]", mcp_session.ClientSession + ) pool = _pool.get() @@ -80,7 +99,7 @@ async def _get_or_create_connection( streams = await exit_stack.enter_async_context(transport_factory()) read_stream, write_stream = streams[0], streams[1] - client = _mcp_session.ClientSession( + client = client_session( read_stream=read_stream, write_stream=write_stream, ) @@ -105,7 +124,10 @@ def _make_tool_fn( """Create a tool function that manages its own connection.""" async def call_tool(**kwargs: Any) -> Any: - import mcp.types as _mcp_types # noqa: PLC0415 + mcp_types = _import_mcp_module("mcp.types") + text_content = cast( + "type[mcp.types.TextContent]", mcp_types.TextContent + ) client = await _get_or_create_connection( connection_key, transport_factory @@ -124,7 +146,7 @@ async def call_tool(**kwargs: Any) -> Any: error_text = " ".join( part.text for part in result.content - if isinstance(part, _mcp_types.TextContent) + if isinstance(part, text_content) ) raise RuntimeError( f"MCP tool error: {error_text or 'Unknown error'}" @@ -134,7 +156,7 @@ async def call_tool(**kwargs: Any) -> Any: return result.structuredContent for part in result.content: - if isinstance(part, _mcp_types.TextContent): + if isinstance(part, text_content): text = part.text if text.startswith(("{", "[")): try: @@ -177,18 +199,21 @@ async def get_stdio_tools( ) """ - import mcp.client.stdio as _mcp_stdio # noqa: PLC0415 + mcp_stdio = _import_mcp_module("mcp.client.stdio") connection_key = f"stdio:{command}:{':'.join(args)}" def transport_factory() -> contextlib.AbstractAsyncContextManager[Any]: - return _mcp_stdio.stdio_client( - _mcp_stdio.StdioServerParameters( - command=command, - args=list(args), - env=env, - cwd=cwd, - ) + return cast( + "contextlib.AbstractAsyncContextManager[Any]", + mcp_stdio.stdio_client( + mcp_stdio.StdioServerParameters( + command=command, + args=list(args), + env=env, + cwd=cwd, + ) + ), ) client = await _get_or_create_connection(connection_key, transport_factory) @@ -230,14 +255,16 @@ async def get_http_tools( """ import httpx as _httpx # noqa: PLC0415 - import mcp.client.streamable_http as _mcp_http # noqa: PLC0415 + + mcp_http = _import_mcp_module("mcp.client.streamable_http") connection_key = f"http:{url}" def transport_factory() -> contextlib.AbstractAsyncContextManager[Any]: http_client = _httpx.AsyncClient(headers=headers) if headers else None - return _mcp_http.streamable_http_client( - url=url, http_client=http_client + return cast( + "contextlib.AbstractAsyncContextManager[Any]", + mcp_http.streamable_http_client(url=url, http_client=http_client), ) async with ensure_connection_pool(): diff --git a/tests/agents/mcp/test_client.py b/tests/agents/mcp/test_client.py index 4af10dc4..13a73cc7 100644 --- a/tests/agents/mcp/test_client.py +++ b/tests/agents/mcp/test_client.py @@ -4,9 +4,11 @@ import contextlib import dataclasses +import importlib from typing import Any import mcp.types +import pytest import ai from ai.agents.mcp.client import _mcp_tool_to_native @@ -75,6 +77,24 @@ def test_mcp_tool_to_native_schema_preserved() -> None: assert _function_args(native).description == "Echo input" +async def test_get_http_tools_raises_installation_error_without_mcp( + monkeypatch: pytest.MonkeyPatch, +) -> None: + real_import_module = importlib.import_module + + def fake_import_module(name: str, package: str | None = None) -> Any: + if name == "mcp.client.streamable_http": + raise ModuleNotFoundError("No module named 'mcp'", name="mcp") + return real_import_module(name, package) + + monkeypatch.setattr(importlib, "import_module", fake_import_module) + + with pytest.raises(ai.InstallationError) as exc_info: + await ai.mcp.get_http_tools("https://mcp.example.com/mcp") + + assert "ai[mcp]" in str(exc_info.value) + + # -- End-to-end: MCP tool executes through Agent default loop --------------- diff --git a/uv.lock b/uv.lock index b8c3d9a6..0f3f5c68 100644 --- a/uv.lock +++ b/uv.lock @@ -16,7 +16,6 @@ name = "ai" source = { editable = "." } dependencies = [ { name = "httpx" }, - { name = "mcp" }, { name = "modelsdotdev" }, { name = "pydantic" }, { name = "typing-extensions" }, @@ -26,6 +25,9 @@ dependencies = [ anthropic = [ { name = "anthropic" }, ] +mcp = [ + { name = "mcp" }, +] openai = [ { name = "openai" }, ] @@ -34,6 +36,7 @@ openai = [ dev = [ { name = "anthropic" }, { name = "async-solipsism" }, + { name = "mcp" }, { name = "mypy" }, { name = "openai" }, { name = "pytest" }, @@ -54,18 +57,19 @@ examples = [ requires-dist = [ { name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.83.0" }, { name = "httpx", specifier = ">=0.28.1" }, - { name = "mcp", specifier = ">=1.18.0" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.18.0" }, { name = "modelsdotdev", specifier = "==0.*" }, { name = "openai", marker = "extra == 'openai'", specifier = ">=2.14.0" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "typing-extensions", specifier = ">=4.15.0" }, ] -provides-extras = ["anthropic", "openai"] +provides-extras = ["anthropic", "mcp", "openai"] [package.metadata.requires-dev] dev = [ { name = "anthropic", specifier = ">=0.83.0" }, { name = "async-solipsism", specifier = ">=0.9" }, + { name = "mcp", specifier = ">=1.18.0" }, { name = "mypy", specifier = "~=2.1.0" }, { name = "openai", specifier = ">=2.14.0" }, { name = "pytest", specifier = ">=8.0" }, @@ -617,7 +621,7 @@ linkify = [ [[package]] name = "mcp" -version = "1.25.0" +version = "1.27.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -635,9 +639,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/2d/649d80a0ecf6a1f82632ca44bec21c0461a9d9fc8934d38cb5b319f2db5e/mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802", size = 605387, upload-time = "2025-12-19T10:19:56.985Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/83/d1efe7c2980d8a3afa476f4e3d42d53dd54c0ab94c27bee5d755b45c8b73/mcp-1.27.1.tar.gz", hash = "sha256:0f47e1820f8f8f941466b39749eb1d1839a04caddca2bc60e9d46e8a99914924", size = 608458, upload-time = "2026-05-08T16:50:12.601Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/fc/6dc7659c2ae5ddf280477011f4213a74f806862856b796ef08f028e664bf/mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a", size = 233076, upload-time = "2025-12-19T10:19:55.416Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/42d9596facebdb533b7f0b86c1b0364ef350d1f8ba78b1052e8a58b48b65/mcp-1.27.1-py3-none-any.whl", hash = "sha256:1af3c4203b329430fde7a87b4fcb6392a041f5cb851fd68fc674016ab4e7c06f", size = 216260, upload-time = "2026-05-08T16:50:10.547Z" }, ] [[package]]