main
py 306 lines 10.3 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # 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 from types import SimpleNamespace
8 from unittest.mock import AsyncMock, MagicMock
9
10 import pytest
11 import pytest_asyncio
12 from openai import OpenAI
13 from openai_harmony import Message, ToolDescription, ToolNamespaceConfig
14
15 import vllm.entrypoints.mcp.tool_server as tool_server_module
16 from tests.utils import RemoteOpenAIServer
17 from vllm.entrypoints.mcp.tool_server import (
18 MCPToolServer,
19 init_tool_server,
20 post_process_tools_description,
21 )
22
23 from .conftest import (
24 BASE_TEST_ENV,
25 events_contain_type,
26 log_response_diagnostics,
27 retry_for_tool_call,
28 retry_streaming_for,
29 validate_streaming_event_stack,
30 )
31
32 MODEL_NAME = "openai/gpt-oss-20b"
33
34 _BASE_SERVER_ARGS = [
35 "--enforce-eager",
36 "--tool-server",
37 "demo",
38 "--max_model_len",
39 "5000",
40 ]
41
42 _PYTHON_TOOL_INSTRUCTION = (
43 "You must use the Python tool to execute code. Never simulate execution."
44 )
45
46
47 @pytest.mark.asyncio
48 async def test_init_tool_server_without_configuration():
49 args = SimpleNamespace(tool_server=None)
50
51 assert await init_tool_server(args) is None
52
53
54 @pytest.mark.asyncio
55 async def test_init_tool_server_with_mcp_url(monkeypatch: pytest.MonkeyPatch):
56 server = MagicMock()
57 server.add_tool_server = AsyncMock()
58 monkeypatch.setattr(tool_server_module, "MCPToolServer", lambda: server)
59
60 result = await init_tool_server(SimpleNamespace(tool_server="http://mcp.test"))
61
62 assert result is server
63 server.add_tool_server.assert_awaited_once_with("http://mcp.test")
64
65
66 @pytest.mark.skip_global_cleanup
67 class TestMCPToolServerUnit:
68 """Test MCP tool description processing and filtering.
69
70 Note: The wildcard "*" is normalized to None by
71 _extract_allowed_tools_from_mcp_requests before reaching this layer,
72 so we only test None and specific tool filtering here.
73 See responses/test_serving_responses.py for "*" normalization tests.
74 """
75
76 def test_get_tool_description(self):
77 pytest.importorskip("mcp")
78
79 server = MCPToolServer()
80 tool1 = ToolDescription.new(
81 name="tool1", description="First", parameters={"type": "object"}
82 )
83 tool2 = ToolDescription.new(
84 name="tool2", description="Second", parameters={"type": "object"}
85 )
86 tool3 = ToolDescription.new(
87 name="tool3", description="Third", parameters={"type": "object"}
88 )
89
90 server.harmony_tool_descriptions = {
91 "test_server": ToolNamespaceConfig(
92 name="test_server",
93 description="test",
94 tools=[tool1, tool2, tool3],
95 )
96 }
97
98 # Nonexistent server
99 assert server.get_tool_description("nonexistent") is None
100
101 # None (no filter) - returns all tools
102 result = server.get_tool_description("test_server", allowed_tools=None)
103 assert len(result.tools) == 3
104
105 # Filter to specific tools
106 result = server.get_tool_description(
107 "test_server", allowed_tools=["tool1", "tool3"]
108 )
109 assert len(result.tools) == 2
110 assert result.tools[0].name == "tool1"
111 assert result.tools[1].name == "tool3"
112
113 # Single tool
114 result = server.get_tool_description("test_server", allowed_tools=["tool2"])
115 assert len(result.tools) == 1
116 assert result.tools[0].name == "tool2"
117
118 # No matching tools - returns None
119 result = server.get_tool_description(
120 "test_server", allowed_tools=["nonexistent"]
121 )
122 assert result is None
123
124 # Empty list - returns None
125 assert server.get_tool_description("test_server", allowed_tools=[]) is None
126
127 def test_post_process_tools_description_uses_trim_schema_result(self, monkeypatch):
128 mcp_types = pytest.importorskip("mcp.types")
129 tool = mcp_types.Tool(
130 name="tool",
131 input_schema={"type": "object"},
132 )
133 result = mcp_types.ListToolsResult(tools=[tool])
134 trimmed_schema = {"type": "object", "properties": {}}
135
136 monkeypatch.setattr(
137 "vllm.entrypoints.mcp.tool_server.trim_schema",
138 lambda _schema: trimmed_schema,
139 )
140
141 post_process_tools_description(result)
142
143 assert tool.input_schema is trimmed_schema
144
145 def test_post_process_tools_description_honors_meta_opt_out(self):
146 mcp_types = pytest.importorskip("mcp.types")
147 included = mcp_types.Tool(
148 name="included",
149 input_schema={"type": "object"},
150 )
151 excluded = mcp_types.Tool(
152 name="excluded",
153 input_schema={"type": "object"},
154 meta={"include_in_prompt": False},
155 )
156 result = mcp_types.ListToolsResult(tools=[included, excluded])
157
158 post_process_tools_description(result)
159
160 assert result.tools == [included]
161
162 def test_builtin_tools_consistency(self):
163 """MCP_BUILTIN_TOOLS must match BUILTIN_TOOL_TO_MCP_SERVER_LABEL values."""
164 from vllm.entrypoints.openai.parser.harmony_utils import (
165 BUILTIN_TOOL_TO_MCP_SERVER_LABEL,
166 MCP_BUILTIN_TOOLS,
167 )
168
169 assert set(BUILTIN_TOOL_TO_MCP_SERVER_LABEL.values()) == MCP_BUILTIN_TOOLS, (
170 f"MCP_BUILTIN_TOOLS {MCP_BUILTIN_TOOLS} does not match "
171 f"BUILTIN_TOOL_TO_MCP_SERVER_LABEL values "
172 f"{set(BUILTIN_TOOL_TO_MCP_SERVER_LABEL.values())}"
173 )
174
175
176 class TestMCPEnabled:
177 """Tests that require MCP tools to be enabled via environment variable."""
178
179 @pytest.fixture(scope="class")
180 def mcp_enabled_server(self):
181 env_dict = {
182 **BASE_TEST_ENV,
183 "VLLM_ENABLE_RESPONSES_API_STORE": "1",
184 "PYTHON_EXECUTION_BACKEND": "dangerously_use_uv",
185 "VLLM_GPT_OSS_SYSTEM_TOOL_MCP_LABELS": ("code_interpreter,container"),
186 "VLLM_GPT_OSS_HARMONY_SYSTEM_INSTRUCTIONS": "1",
187 }
188 with RemoteOpenAIServer(
189 MODEL_NAME, list(_BASE_SERVER_ARGS), env_dict=env_dict
190 ) as remote_server:
191 yield remote_server
192
193 @pytest_asyncio.fixture
194 async def client(self, mcp_enabled_server):
195 async with mcp_enabled_server.get_async_client() as async_client:
196 yield async_client
197
198 @staticmethod
199 def _mcp_tools_payload(*, allowed_tools: list[str] | None = None) -> list[dict]:
200 tool: dict = {
201 "type": "mcp",
202 "server_label": "code_interpreter",
203 "server_url": "http://localhost:8888",
204 }
205 if allowed_tools is not None:
206 tool["allowed_tools"] = allowed_tools
207 return [tool]
208
209 @staticmethod
210 def _python_exec_input(code: str = "") -> str:
211 if not code:
212 code = "import random; print(random.randint(1, 1000000))"
213 return f"Execute the following code: {code}"
214
215 @pytest.mark.asyncio
216 @pytest.mark.parametrize("model_name", [MODEL_NAME])
217 async def test_mcp_tool_env_flag_enabled(self, client: OpenAI, model_name: str):
218 response = await retry_for_tool_call(
219 client,
220 model=model_name,
221 expected_tool_type="mcp_call",
222 input=self._python_exec_input(),
223 instructions=_PYTHON_TOOL_INSTRUCTION,
224 tools=self._mcp_tools_payload(),
225 temperature=0.0,
226 extra_body={"enable_response_messages": True},
227 )
228
229 assert response.status == "completed"
230 log_response_diagnostics(response, label="MCP Enabled")
231
232 tool_call_found = False
233 tool_response_found = False
234 for message in response.output_messages:
235 recipient = message.get("recipient")
236 if recipient and recipient.startswith("python"):
237 tool_call_found = True
238 assert message.get("channel") == "commentary"
239 parsed_message = Message.from_dict(message)
240 if parsed_message.author.role == "tool" and (
241 parsed_message.author.name or ""
242 ).startswith("python"):
243 tool_response_found = True
244 assert message.get("channel") == "commentary"
245
246 assert tool_call_found, (
247 f"No Python tool call found. "
248 f"Output types: "
249 f"{[getattr(o, 'type', None) for o in response.output]}"
250 )
251 assert tool_response_found, "No Python tool response found"
252
253 for message in response.input_messages:
254 assert Message.from_dict(message).author.role != "developer"
255
256 @pytest.mark.asyncio
257 @pytest.mark.parametrize("model_name", [MODEL_NAME])
258 async def test_mcp_tool_with_allowed_tools_star(
259 self, client: OpenAI, model_name: str
260 ):
261 response = await retry_for_tool_call(
262 client,
263 model=model_name,
264 expected_tool_type="mcp_call",
265 input=self._python_exec_input(),
266 instructions=_PYTHON_TOOL_INSTRUCTION,
267 tools=self._mcp_tools_payload(allowed_tools=["*"]),
268 temperature=0.0,
269 extra_body={"enable_response_messages": True},
270 )
271
272 assert response.status == "completed"
273 log_response_diagnostics(response, label="MCP Allowed Tools *")
274
275 tool_call_found = any(
276 (msg.get("recipient") or "").startswith("python")
277 for msg in response.output_messages
278 )
279 assert tool_call_found, (
280 f"No Python tool call with '*'. "
281 f"Output types: "
282 f"{[getattr(o, 'type', None) for o in response.output]}"
283 )
284
285 @pytest.mark.asyncio
286 @pytest.mark.parametrize("model_name", [MODEL_NAME])
287 async def test_mcp_tool_calling_streaming_types(
288 self,
289 pairs_of_event_types: dict[str, str],
290 client: OpenAI,
291 model_name: str,
292 ):
293 def _has_mcp_events(events: list) -> bool:
294 return events_contain_type(events, "mcp_call")
295
296 events = await retry_streaming_for(
297 client,
298 model=model_name,
299 validate_events=_has_mcp_events,
300 input=("What is 123 * 456? Use Python to calculate the result."),
301 tools=[{"type": "mcp", "server_label": "code_interpreter"}],
302 instructions=_PYTHON_TOOL_INSTRUCTION,
303 temperature=0.0,
304 )
305
306 validate_streaming_event_stack(events, pairs_of_event_types)