diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml deleted file mode 100644 index af5aea9..0000000 --- a/.github/workflows/changelog.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: "Update Changelogs" - -on: - release: - types: [released] - -jobs: - update: - runs-on: ubuntu-latest - - permissions: - # Give the default GITHUB_TOKEN write permission to commit and push the - # updated CHANGELOG back to the repository. - # https://github.blog/changelog/2023-02-02-github-actions-updating-the-default-github_token-permissions-to-read-only/ - contents: write - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - ref: ${{ github.event.release.target_commitish }} - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Update Changelog - uses: stefanzweifel/changelog-updater-action@v1 - with: - latest-version: ${{ github.event.release.tag_name }} - release-notes: ${{ github.event.release.body }} - - - name: Update Docs Changelog - run: | - # Get current date in the required format - CURRENT_DATE=$(date +"%Y‑%m‑%d") - - # Create the new changelog entry - NEW_ENTRY=" - ## ${{ github.event.release.tag_name }} - ${{ github.event.release.body }} - - - " - - # Read the current changelog and insert the new entry after the front matter - python -c " - import re - - # Read the current changelog - with open('docs/changelog.mdx', 'r') as f: - content = f.read() - - # Find the end of the front matter - front_matter_end = content.find('---', content.find('---') + 1) + 3 - - # Split content into front matter and body - front_matter = content[:front_matter_end] - body = content[front_matter_end:] - - # Create new entry - new_entry = '''$NEW_ENTRY''' - - # Combine and write back - new_content = front_matter + '\n\n' + new_entry + body.lstrip() - - with open('docs/changelog.mdx', 'w') as f: - f.write(new_content) - " - - - name: Commit updated CHANGELOG - uses: stefanzweifel/git-auto-commit-action@v5 - with: - branch: ${{ github.event.release.target_commitish }} - commit_message: Update CHANGELOG and docs changelog - file_pattern: CHANGELOG.md docs/changelog.mdx diff --git a/docs/changelog/1_3_11.mdx b/docs/changelog/1_3_11.mdx index 144cf3f..9511a42 100644 --- a/docs/changelog/1_3_11.mdx +++ b/docs/changelog/1_3_11.mdx @@ -1,7 +1,7 @@ --- title: "Version 1.3.11" description: "Enhanced error handling, authorization features, and critical bug fixes" -mode: "center" +icon: "key" --- ![Release Image 1.3.11](/images/Release1.3.11.png) diff --git a/docs/changelog/changelog.mdx b/docs/changelog/changelog.mdx index dbea48b..bb92175 100644 --- a/docs/changelog/changelog.mdx +++ b/docs/changelog/changelog.mdx @@ -1,11 +1,11 @@ --- title: "Library Updates" description: "New updates and improvements" -mode: "center" +icon: "history" --- - - ## v1.3.11 + + ## Auth Revolution - **New**: Complete OAuth2 and Bearer token authentication framework ([#149](https://github.com/pietrozullo/mcp-use/pull/149)) - Fix: HTTP connector error handling with better diagnostics ([#279](https://github.com/pietrozullo/mcp-use/pull/279)) - Fix: Accept empty tool results without validation errors ([#273](https://github.com/pietrozullo/mcp-use/pull/273)) @@ -15,16 +15,16 @@ mode: "center" - Fix: Subprocess cleanup for multiple MCP clients to prevent memory leaks ([#231](https://github.com/pietrozullo/mcp-use/pull/231)) - - ## v1.3.3 + + ## Stability Boost - Set default logging level to info - Fix: prevent double async cleanup and event loop errors in astream - Fix: Raise import error for fastembed, server manager does not silently fail - Fix: search tools tuple unpacking error - - ## v1.3.1 + + ## Stream Power - Remove client options for easier usage - Add streamable HTTP support - Fix websocket error positional arguments (headers were missing) @@ -32,40 +32,40 @@ mode: "center" - Add CLAUDE.md for development guidance - - ## v1.3.0 + + ## Sandbox Magic - Added optional E2B sandbox execution so MCP servers can run in secure cloud sandboxes. - `MCPAgent.astream()` now lets you stream results **and** automatically log full conversation history. - - ## v1.2.13 + + ## Resource Discovery - Alpha support for **Resources** & **Prompts** exposed by remote servers. - Routine version bump and stability tweaks across task / connection managers. - - ## v1.2.10 + + ## Emergency Patch - Hot‑fix: patched **FastEmbed** import failure that could break vector search. - - ## v1.1.5 + + ## Polish Pass - Maintenance release – internal refactors, doc clean‑ups, and incremental API polish. - - ## v1.0.1 + + ## Transport Layer - Introduced HTTP transport layer and dynamic multi‑server selection. - - ## v1.0.0 + + ## Stable Launch - First stable release of the unified Python client after the 0.0.x preview series. - - ## v0.0.6 + + ## Public Preview - Initial public preview published to PyPI; automated publish workflow enabled. diff --git a/tests/unit/test_agent.py b/tests/unit/test_agent.py new file mode 100644 index 0000000..ba8cc98 --- /dev/null +++ b/tests/unit/test_agent.py @@ -0,0 +1,234 @@ +""" +Unit tests for the MCPAgent class. +""" + +from unittest.mock import MagicMock, patch + +import pytest +from langchain.schema import HumanMessage +from langchain_core.agents import AgentFinish + +from mcp_use.agents.mcpagent import MCPAgent +from mcp_use.client import MCPClient +from mcp_use.connectors.base import BaseConnector + + +class TestMCPAgentInitialization: + """Tests for MCPAgent initialization""" + + def _mock_llm(self): + llm = MagicMock() + llm._llm_type = "test-provider" + llm._identifying_params = {"model": "test-model"} + return llm + + def test_init_with_llm_and_client(self): + """Initializing locally with LLM and client.""" + llm = self._mock_llm() + client = MagicMock(spec=MCPClient) + + agent = MCPAgent(llm=llm, client=client) + + assert agent.llm is llm + assert agent.client is client + assert agent._is_remote is False + assert agent._initialized is False + assert agent._agent_executor is None + assert isinstance(agent.tools_used_names, list) + + def test_init_requires_llm_for_local(self): + """Omitting LLM for local execution raises ValueError.""" + with pytest.raises(ValueError) as exc: + MCPAgent(client=MagicMock(spec=MCPClient)) + assert "llm is required for local execution" in str(exc.value) + + def test_init_requires_client_or_connectors(self): + """LLM present but no client/connectors raises ValueError.""" + llm = self._mock_llm() + with pytest.raises(ValueError) as exc: + MCPAgent(llm=llm) + assert "Either client or connector must be provided" in str(exc.value) + + def test_init_with_connectors_only(self): + """LLM with connectors initializes without client.""" + llm = self._mock_llm() + connector = MagicMock(spec=BaseConnector) + + agent = MCPAgent(llm=llm, connectors=[connector]) + + assert agent.client is None + assert agent.connectors == [connector] + assert agent._is_remote is False + + def test_server_manager_requires_client(self): + """Using server manager without client raises ValueError.""" + llm = self._mock_llm() + with pytest.raises(ValueError) as exc: + MCPAgent(llm=llm, connectors=[MagicMock(spec=BaseConnector)], use_server_manager=True) + assert "Client must be provided when using server manager" in str(exc.value) + + def test_init_remote_mode_with_agent_id(self): + """Providing agent_id enables remote mode and skips local requirements.""" + with patch("mcp_use.agents.mcpagent.RemoteAgent") as MockRemote: + agent = MCPAgent(agent_id="abc123", api_key="k", base_url="https://x") + + MockRemote.assert_called_once() + assert agent._is_remote is True + assert agent._remote_agent is not None + + +class TestMCPAgentRun: + """Tests for MCPAgent.run""" + + def _mock_llm(self): + llm = MagicMock() + llm._llm_type = "test-provider" + llm._identifying_params = {"model": "test-model"} + llm.with_structured_output = MagicMock(return_value=llm) + return llm + + @pytest.mark.asyncio + async def test_run_remote_delegates(self): + """In remote mode, run delegates to RemoteAgent.run and returns its result.""" + with patch("mcp_use.agents.mcpagent.RemoteAgent") as MockRemote: + remote_instance = MockRemote.return_value + remote_instance.run = MagicMock() + + async def _arun(*args, **kwargs): + return "remote-result" + + remote_instance.run.side_effect = _arun + + agent = MCPAgent(agent_id="abc123", api_key="k", base_url="https://x") + + result = await agent.run("hello", max_steps=3, external_history=["h"], output_schema=None) + + remote_instance.run.assert_called_once() + assert result == "remote-result" + + @pytest.mark.asyncio + async def test_run_local_calls_stream_and_consume(self): + """Local run creates stream generator and consumes it via _consume_and_return.""" + llm = self._mock_llm() + client = MagicMock(spec=MCPClient) + + agent = MCPAgent(llm=llm, client=client) + + async def dummy_gen(): + if False: + yield None + + with ( + patch.object(MCPAgent, "stream", return_value=dummy_gen()) as mock_stream, + patch.object(MCPAgent, "_consume_and_return") as mock_consume, + ): + + async def _aconsume(gen): + return ("ok", 1) + + mock_consume.side_effect = _aconsume + + result = await agent.run("query", max_steps=2, manage_connector=True, external_history=None) + + mock_stream.assert_called_once() + mock_consume.assert_called_once() + assert result == "ok" + + +class TestMCPAgentStream: + """Tests for MCPAgent.stream""" + + def _mock_llm(self): + llm = MagicMock() + llm._llm_type = "test-provider" + llm._identifying_params = {"model": "test-model"} + llm.with_structured_output = MagicMock(return_value=llm) + return llm + + @pytest.mark.asyncio + async def test_stream_remote_delegates(self): + """In remote mode, stream delegates to RemoteAgent.stream and yields its items.""" + + async def _astream(*args, **kwargs): + yield "remote-yield-1" + yield "remote-yield-2" + + with patch("mcp_use.agents.mcpagent.RemoteAgent") as MockRemote: + remote_instance = MockRemote.return_value + remote_instance.stream = MagicMock(side_effect=_astream) + + agent = MCPAgent(agent_id="abc123", api_key="k", base_url="https://x") + + outputs = [] + async for item in agent.stream("hello", max_steps=2): + outputs.append(item) + + remote_instance.stream.assert_called_once() + assert outputs == ["remote-yield-1", "remote-yield-2"] + + @pytest.mark.asyncio + async def test_stream_initializes_and_finishes(self): + """When not initialized, stream calls initialize, sets max_steps, and yields final output on AgentFinish.""" + llm = self._mock_llm() + client = MagicMock(spec=MCPClient) + agent = MCPAgent(llm=llm, client=client) + agent.callbacks = [] + agent.telemetry = MagicMock() + + executor = MagicMock() + executor.max_iterations = None + + async def _init_side_effect(): + agent._agent_executor = executor + agent._initialized = True + + with patch.object(MCPAgent, "initialize", side_effect=_init_side_effect) as mock_init: + + async def _atake_next_step(**kwargs): + return AgentFinish(return_values={"output": "done"}, log="") + + executor._atake_next_step = MagicMock(side_effect=_atake_next_step) + + outputs = [] + async for item in agent.stream("q", max_steps=3): + outputs.append(item) + + mock_init.assert_called_once() + assert executor.max_iterations == 3 + assert outputs[-1] == "done" + agent.telemetry.track_agent_execution.assert_called_once() + + @pytest.mark.asyncio + async def test_stream_uses_external_history_and_sets_max_steps(self): + """External history should be used, and executor.max_iterations should reflect max_steps arg.""" + llm = self._mock_llm() + client = MagicMock(spec=MCPClient) + agent = MCPAgent(llm=llm, client=client) + agent.callbacks = [] + agent.telemetry = MagicMock() + + external_history = [HumanMessage(content="past")] + + executor = MagicMock() + executor.max_iterations = None + + async def _init_side_effect(): + agent._agent_executor = executor + agent._initialized = True + + with patch.object(MCPAgent, "initialize", side_effect=_init_side_effect): + + async def _asserting_step( + name_to_tool_map=None, color_mapping=None, inputs=None, intermediate_steps=None, run_manager=None + ): + assert inputs["chat_history"] == [msg for msg in external_history] + return AgentFinish(return_values={"output": "ok"}, log="") + + executor._atake_next_step = MagicMock(side_effect=_asserting_step) + + outputs = [] + async for item in agent.stream("query", max_steps=4, external_history=external_history): + outputs.append(item) + + assert executor.max_iterations == 4 + assert outputs[-1] == "ok"