"vscode:/vscode.git/clone" did not exist on "4ff79a136ec466684e74502057acba578cfe947c"
test_mcp_tools.py 8.29 KB
Newer Older
1
2
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
"""Integration tests for MCP tool support in the Responses API."""
4

5
from __future__ import annotations
6

7
8
9
import pytest
import pytest_asyncio
from openai import OpenAI
10
11
from openai_harmony import ToolDescription, ToolNamespaceConfig

12
from vllm.entrypoints.mcp.tool_server import MCPToolServer
13

14
from ....utils import RemoteOpenAIServer
15
16
17
18
19
20
21
22
from .conftest import (
    BASE_TEST_ENV,
    events_contain_type,
    log_response_diagnostics,
    retry_for_tool_call,
    retry_streaming_for,
    validate_streaming_event_stack,
)
23
24
25

MODEL_NAME = "openai/gpt-oss-20b"

26
27
28
29
30
31
32
_BASE_SERVER_ARGS = [
    "--enforce-eager",
    "--tool-server",
    "demo",
    "--max_model_len",
    "5000",
]
33

34
35
36
37
38
39
_PYTHON_TOOL_INSTRUCTION = (
    "You must use the Python tool to execute code. Never simulate execution."
)


class TestMCPToolServerUnit:
40
41
42
43
44
45
46
    """Test MCPToolServer.get_tool_description filtering logic.

    Note: The wildcard "*" is normalized to None by
    _extract_allowed_tools_from_mcp_requests before reaching this layer,
    so we only test None and specific tool filtering here.
    See test_serving_responses.py for "*" normalization tests.
    """
47
48
49
50
51
52
53
54
55
56

    def test_get_tool_description(self):
        pytest.importorskip("mcp")

        server = MCPToolServer()
        tool1 = ToolDescription.new(
            name="tool1", description="First", parameters={"type": "object"}
        )
        tool2 = ToolDescription.new(
            name="tool2", description="Second", parameters={"type": "object"}
57
        )
58
59
60
61
62
63
64
65
66
67
68
        tool3 = ToolDescription.new(
            name="tool3", description="Third", parameters={"type": "object"}
        )

        server.harmony_tool_descriptions = {
            "test_server": ToolNamespaceConfig(
                name="test_server",
                description="test",
                tools=[tool1, tool2, tool3],
            )
        }
69

70
71
        # Nonexistent server
        assert server.get_tool_description("nonexistent") is None
72

73
74
75
        # None (no filter) - returns all tools
        result = server.get_tool_description("test_server", allowed_tools=None)
        assert len(result.tools) == 3
76

77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
        # Filter to specific tools
        result = server.get_tool_description(
            "test_server", allowed_tools=["tool1", "tool3"]
        )
        assert len(result.tools) == 2
        assert result.tools[0].name == "tool1"
        assert result.tools[1].name == "tool3"

        # Single tool
        result = server.get_tool_description("test_server", allowed_tools=["tool2"])
        assert len(result.tools) == 1
        assert result.tools[0].name == "tool2"

        # No matching tools - returns None
        result = server.get_tool_description(
            "test_server", allowed_tools=["nonexistent"]
        )
        assert result is None
95

96
97
        # Empty list - returns None
        assert server.get_tool_description("test_server", allowed_tools=[]) is None
98

99
    def test_builtin_tools_consistency(self):
100
        """MCP_BUILTIN_TOOLS must match BUILTIN_TOOL_TO_MCP_SERVER_LABEL values."""
101
        from vllm.entrypoints.openai.parser.harmony_utils import (
102
            BUILTIN_TOOL_TO_MCP_SERVER_LABEL,
103
104
            MCP_BUILTIN_TOOLS,
        )
105

106
        assert set(BUILTIN_TOOL_TO_MCP_SERVER_LABEL.values()) == MCP_BUILTIN_TOOLS, (
107
            f"MCP_BUILTIN_TOOLS {MCP_BUILTIN_TOOLS} does not match "
108
109
            f"BUILTIN_TOOL_TO_MCP_SERVER_LABEL values "
            f"{set(BUILTIN_TOOL_TO_MCP_SERVER_LABEL.values())}"
110
        )
111
112
113
114
115
116


class TestMCPEnabled:
    """Tests that require MCP tools to be enabled via environment variable."""

    @pytest.fixture(scope="class")
117
118
119
120
121
122
123
124
125
126
127
128
    def mcp_enabled_server(self):
        env_dict = {
            **BASE_TEST_ENV,
            "VLLM_ENABLE_RESPONSES_API_STORE": "1",
            "PYTHON_EXECUTION_BACKEND": "dangerously_use_uv",
            "VLLM_GPT_OSS_SYSTEM_TOOL_MCP_LABELS": ("code_interpreter,container"),
            "VLLM_GPT_OSS_HARMONY_SYSTEM_INSTRUCTIONS": "1",
        }
        with RemoteOpenAIServer(
            MODEL_NAME, list(_BASE_SERVER_ARGS), env_dict=env_dict
        ) as remote_server:
            yield remote_server
129
130

    @pytest_asyncio.fixture
131
    async def client(self, mcp_enabled_server):
132
133
134
        async with mcp_enabled_server.get_async_client() as async_client:
            yield async_client

135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
    @staticmethod
    def _mcp_tools_payload(*, allowed_tools: list[str] | None = None) -> list[dict]:
        tool: dict = {
            "type": "mcp",
            "server_label": "code_interpreter",
            "server_url": "http://localhost:8888",
        }
        if allowed_tools is not None:
            tool["allowed_tools"] = allowed_tools
        return [tool]

    @staticmethod
    def _python_exec_input(code: str = "") -> str:
        if not code:
            code = "import random; print(random.randint(1, 1000000))"
        return f"Execute the following code: {code}"

152
153
    @pytest.mark.asyncio
    @pytest.mark.parametrize("model_name", [MODEL_NAME])
154
155
156
    async def test_mcp_tool_env_flag_enabled(self, client: OpenAI, model_name: str):
        response = await retry_for_tool_call(
            client,
157
            model=model_name,
158
159
160
161
162
            expected_tool_type="mcp_call",
            input=self._python_exec_input(),
            instructions=_PYTHON_TOOL_INSTRUCTION,
            tools=self._mcp_tools_payload(),
            temperature=0.0,
163
164
            extra_body={"enable_response_messages": True},
        )
165

166
        assert response.status == "completed"
167
168
        log_response_diagnostics(response, label="MCP Enabled")

169
170
171
172
173
174
        tool_call_found = False
        tool_response_found = False
        for message in response.output_messages:
            recipient = message.get("recipient")
            if recipient and recipient.startswith("python"):
                tool_call_found = True
175
                assert message.get("channel") == "commentary"
176
            author = message.get("author", {})
177
178
            if author.get("role") == "tool" and (author.get("name") or "").startswith(
                "python"
179
180
            ):
                tool_response_found = True
181
                assert message.get("channel") == "commentary"
182

183
184
185
186
        assert tool_call_found, (
            f"No Python tool call found. "
            f"Output types: "
            f"{[getattr(o, 'type', None) for o in response.output]}"
187
        )
188
189
        assert tool_response_found, "No Python tool response found"

190
        for message in response.input_messages:
191
            assert message.get("author", {}).get("role") != "developer"
192
193
194
195

    @pytest.mark.asyncio
    @pytest.mark.parametrize("model_name", [MODEL_NAME])
    async def test_mcp_tool_with_allowed_tools_star(
196
        self, client: OpenAI, model_name: str
197
    ):
198
199
        response = await retry_for_tool_call(
            client,
200
            model=model_name,
201
202
203
204
205
            expected_tool_type="mcp_call",
            input=self._python_exec_input(),
            instructions=_PYTHON_TOOL_INSTRUCTION,
            tools=self._mcp_tools_payload(allowed_tools=["*"]),
            temperature=0.0,
206
207
            extra_body={"enable_response_messages": True},
        )
208

209
        assert response.status == "completed"
210
211
212
213
214
215
        log_response_diagnostics(response, label="MCP Allowed Tools *")

        tool_call_found = any(
            (msg.get("recipient") or "").startswith("python")
            for msg in response.output_messages
        )
216
        assert tool_call_found, (
217
218
219
            f"No Python tool call with '*'. "
            f"Output types: "
            f"{[getattr(o, 'type', None) for o in response.output]}"
220
221
222
223
224
        )

    @pytest.mark.asyncio
    @pytest.mark.parametrize("model_name", [MODEL_NAME])
    async def test_mcp_tool_calling_streaming_types(
225
226
        self,
        pairs_of_event_types: dict[str, str],
227
        client: OpenAI,
228
        model_name: str,
229
    ):
230
231
232
233
234
        def _has_mcp_events(events: list) -> bool:
            return events_contain_type(events, "mcp_call")

        events = await retry_streaming_for(
            client,
235
            model=model_name,
236
237
238
239
240
            validate_events=_has_mcp_events,
            input=("What is 123 * 456? Use Python to calculate the result."),
            tools=[{"type": "mcp", "server_label": "code_interpreter"}],
            instructions=_PYTHON_TOOL_INSTRUCTION,
            temperature=0.0,
241
242
        )

243
        validate_streaming_event_stack(events, pairs_of_event_types)