Skip to content

Commit 014ece9

Browse files
[Frontend] Add tool filtering support to ToolServer (#29224)
Signed-off-by: Daniel Salib <[email protected]> Co-authored-by: Chauncey <[email protected]>
1 parent 62de4f4 commit 014ece9

File tree

5 files changed

+477
-25
lines changed

5 files changed

+477
-25
lines changed
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3+
"""
4+
Example demonstrating MCP (Model Context Protocol) tools with the Responses API.
5+
6+
This example shows how to use MCP tools with different allowed_tools configurations:
7+
1. No filter (allows all tools from the MCP server)
8+
2. Wildcard "*" (explicitly allows all tools)
9+
3. Specific tool names (filters to only those tools)
10+
11+
Set up this example by starting a vLLM OpenAI-compatible server with MCP tools enabled.
12+
For example:
13+
vllm serve openai/gpt-oss-20b --enforce-eager --tool-server demo
14+
15+
Environment variables:
16+
- VLLM_ENABLE_RESPONSES_API_STORE=1
17+
- VLLM_GPT_OSS_SYSTEM_TOOL_MCP_LABELS=code_interpreter,container
18+
- VLLM_GPT_OSS_HARMONY_SYSTEM_INSTRUCTIONS=1
19+
"""
20+
21+
from openai import OpenAI
22+
from utils import get_first_model
23+
24+
25+
def example_no_filter():
26+
"""Example with no allowed_tools filter - allows all tools."""
27+
print("=" * 60)
28+
print("Example 1: No allowed_tools filter (allows all tools)")
29+
print("=" * 60)
30+
31+
base_url = "http://0.0.0.0:8000/v1"
32+
client = OpenAI(base_url=base_url, api_key="empty")
33+
model = get_first_model(client)
34+
35+
response = client.responses.create(
36+
model=model,
37+
input="Execute this code: print('Hello from Python!')",
38+
instructions="Use the Python tool to execute code.",
39+
tools=[
40+
{
41+
"type": "mcp",
42+
"server_label": "code_interpreter",
43+
"server_url": "http://localhost:8888",
44+
# No allowed_tools specified - all tools are available
45+
}
46+
],
47+
)
48+
49+
print(f"Status: {response.status}")
50+
print(f"Output: {response.output_text}")
51+
print()
52+
53+
54+
def example_wildcard():
55+
"""Example with allowed_tools=['*'] - explicitly allows all tools."""
56+
print("=" * 60)
57+
print("Example 2: allowed_tools=['*'] (select all tools)")
58+
print("=" * 60)
59+
60+
base_url = "http://0.0.0.0:8000/v1"
61+
client = OpenAI(base_url=base_url, api_key="empty")
62+
model = get_first_model(client)
63+
64+
response = client.responses.create(
65+
model=model,
66+
input="Execute this code: print('Hello from Python with wildcard!')",
67+
instructions="Use the Python tool to execute code.",
68+
tools=[
69+
{
70+
"type": "mcp",
71+
"server_label": "code_interpreter",
72+
"server_url": "http://localhost:8888",
73+
# Using "*" to explicitly allow all tools from this MCP server
74+
# This is equivalent to not specifying allowed_tools
75+
"allowed_tools": ["*"],
76+
}
77+
],
78+
)
79+
80+
print(f"Status: {response.status}")
81+
print(f"Output: {response.output_text}")
82+
print()
83+
84+
85+
def example_specific_tools():
86+
"""Example with specific allowed_tools list - filters available tools.
87+
88+
Note: This example uses 'web_search_preview' (browser) which has multiple
89+
sub-tools: 'search', 'open', 'find'. The code_interpreter (python) doesn't
90+
have sub-tools, so filtering doesn't apply there.
91+
"""
92+
print("=" * 60)
93+
print("Example 3: allowed_tools=['search'] (filter browser to specific tools)")
94+
print("=" * 60)
95+
96+
base_url = "http://0.0.0.0:8000/v1"
97+
client = OpenAI(base_url=base_url, api_key="empty")
98+
model = get_first_model(client)
99+
100+
response = client.responses.create(
101+
model=model,
102+
input="Search for 'Python programming tutorials'",
103+
instructions="Use the browser tool to search.",
104+
tools=[
105+
{
106+
"type": "mcp",
107+
"server_label": "web_search_preview",
108+
"server_url": "http://localhost:8888",
109+
# Browser has tools: 'search', 'open', 'find'
110+
# Only allow 'search' - blocks 'open' and 'find'
111+
"allowed_tools": ["search"],
112+
}
113+
],
114+
)
115+
116+
print(f"Status: {response.status}")
117+
print(f"Output: {response.output_text}")
118+
print()
119+
120+
121+
def example_object_format():
122+
"""Example using object format for allowed_tools with browser tools."""
123+
print("=" * 60)
124+
print("Example 4: allowed_tools with object format")
125+
print("=" * 60)
126+
127+
base_url = "http://0.0.0.0:8000/v1"
128+
client = OpenAI(base_url=base_url, api_key="empty")
129+
model = get_first_model(client)
130+
131+
response = client.responses.create(
132+
model=model,
133+
input="Search for 'machine learning' and open the first result",
134+
instructions="Use the browser tool.",
135+
tools=[
136+
{
137+
"type": "mcp",
138+
"server_label": "web_search_preview",
139+
"server_url": "http://localhost:8888",
140+
# Object format with tool_names field
141+
# Can also include read_only and other fields
142+
# Browser has tools: 'search', 'open', 'find'
143+
"allowed_tools": {
144+
"tool_names": [
145+
"search",
146+
"open",
147+
], # Allow search and open, block find
148+
"read_only": False,
149+
},
150+
}
151+
],
152+
)
153+
154+
print(f"Status: {response.status}")
155+
print(f"Output: {response.output_text}")
156+
print()
157+
158+
159+
def main():
160+
"""Run all examples."""
161+
print("\n" + "=" * 60)
162+
print("MCP Tools with allowed_tools Examples")
163+
print("=" * 60 + "\n")
164+
165+
# Run all examples
166+
example_no_filter()
167+
example_wildcard()
168+
example_specific_tools()
169+
example_object_format()
170+
171+
print("=" * 60)
172+
print("Summary:")
173+
print(" - No filter or '*' → All tools available from server")
174+
print(" - Specific list → Only those sub-tools available")
175+
print(" - Object format → More control with tool_names field")
176+
print("")
177+
print("Note: allowed_tools filters SUB-TOOLS within an MCP server:")
178+
print(" - code_interpreter (python): No sub-tools to filter")
179+
print(" - web_search_preview (browser): Has 'search', 'open', 'find'")
180+
print("=" * 60)
181+
182+
183+
if __name__ == "__main__":
184+
main()

tests/entrypoints/openai/test_response_api_mcp_tools.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
import pytest
55
import pytest_asyncio
66
from openai import OpenAI
7+
from openai_harmony import ToolDescription, ToolNamespaceConfig
8+
9+
from vllm.entrypoints.tool_server import MCPToolServer
710

811
from ...utils import RemoteOpenAIServer
912

@@ -111,6 +114,48 @@ async def test_mcp_tool_env_flag_enabled(mcp_enabled_client: OpenAI, model_name:
111114
)
112115

113116

117+
@pytest.mark.asyncio
118+
@pytest.mark.parametrize("model_name", [MODEL_NAME])
119+
async def test_mcp_tool_with_allowed_tools_star(
120+
mcp_enabled_client: OpenAI, model_name: str
121+
):
122+
"""Test MCP tool with allowed_tools=['*'] to select all available tools.
123+
124+
This E2E test verifies that the "*" wildcard works end-to-end.
125+
See test_serving_responses.py for detailed unit tests of "*" normalization.
126+
"""
127+
response = await mcp_enabled_client.responses.create(
128+
model=model_name,
129+
input=(
130+
"Execute the following code: "
131+
"import random; print(random.randint(1, 1000000))"
132+
),
133+
instructions=(
134+
"You must use the Python tool to execute code. Never simulate execution."
135+
),
136+
tools=[
137+
{
138+
"type": "mcp",
139+
"server_label": "code_interpreter",
140+
"server_url": "http://localhost:8888",
141+
# Using "*" to allow all tools from this MCP server
142+
"allowed_tools": ["*"],
143+
}
144+
],
145+
extra_body={"enable_response_messages": True},
146+
)
147+
assert response is not None
148+
assert response.status == "completed"
149+
# Verify tool calls work with allowed_tools=["*"]
150+
tool_call_found = False
151+
for message in response.output_messages:
152+
recipient = message.get("recipient")
153+
if recipient and recipient.startswith("python"):
154+
tool_call_found = True
155+
break
156+
assert tool_call_found, "Should have found at least one Python tool call with '*'"
157+
158+
114159
@pytest.mark.asyncio
115160
@pytest.mark.parametrize("model_name", [MODEL_NAME])
116161
async def test_mcp_tool_env_flag_disabled(mcp_disabled_client: OpenAI, model_name: str):
@@ -159,3 +204,58 @@ async def test_mcp_tool_env_flag_disabled(mcp_disabled_client: OpenAI, model_nam
159204
assert message.get("author").get("role") != "developer", (
160205
"No developer messages should be present without a valid tool"
161206
)
207+
208+
209+
def test_get_tool_description():
210+
"""Test MCPToolServer.get_tool_description filtering logic.
211+
212+
Note: The wildcard "*" is normalized to None by
213+
_extract_allowed_tools_from_mcp_requests before reaching this layer,
214+
so we only test None and specific tool filtering here.
215+
See test_serving_responses.py for "*" normalization tests.
216+
"""
217+
pytest.importorskip("mcp")
218+
219+
server = MCPToolServer()
220+
tool1 = ToolDescription.new(
221+
name="tool1", description="First", parameters={"type": "object"}
222+
)
223+
tool2 = ToolDescription.new(
224+
name="tool2", description="Second", parameters={"type": "object"}
225+
)
226+
tool3 = ToolDescription.new(
227+
name="tool3", description="Third", parameters={"type": "object"}
228+
)
229+
230+
server.harmony_tool_descriptions = {
231+
"test_server": ToolNamespaceConfig(
232+
name="test_server", description="test", tools=[tool1, tool2, tool3]
233+
)
234+
}
235+
236+
# Nonexistent server
237+
assert server.get_tool_description("nonexistent") is None
238+
239+
# None (no filter) - returns all tools
240+
result = server.get_tool_description("test_server", allowed_tools=None)
241+
assert len(result.tools) == 3
242+
243+
# Filter to specific tools
244+
result = server.get_tool_description(
245+
"test_server", allowed_tools=["tool1", "tool3"]
246+
)
247+
assert len(result.tools) == 2
248+
assert result.tools[0].name == "tool1"
249+
assert result.tools[1].name == "tool3"
250+
251+
# Single tool
252+
result = server.get_tool_description("test_server", allowed_tools=["tool2"])
253+
assert len(result.tools) == 1
254+
assert result.tools[0].name == "tool2"
255+
256+
# No matching tools - returns None
257+
result = server.get_tool_description("test_server", allowed_tools=["nonexistent"])
258+
assert result is None
259+
260+
# Empty list - returns None
261+
assert server.get_tool_description("test_server", allowed_tools=[]) is None

0 commit comments

Comments
 (0)