main
py 244 lines 7.92 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 from abc import ABC, abstractmethod
4 from contextlib import AbstractAsyncContextManager, asynccontextmanager
5 from typing import TYPE_CHECKING, Any
6
7 from openai_harmony import ToolDescription, ToolNamespaceConfig
8
9 from vllm.entrypoints.mcp.tool import HarmonyBrowserTool, HarmonyPythonTool, Tool
10 from vllm.logger import init_logger
11
12 logger = init_logger(__name__)
13
14 if TYPE_CHECKING:
15 from argparse import Namespace
16
17 from mcp.types import ListToolsResult
18
19
20 async def list_server_and_tools(server_url: str):
21 from mcp import ClientSession
22 from mcp.client.sse import sse_client
23
24 async with (
25 sse_client(url=server_url) as streams,
26 ClientSession(*streams) as session,
27 ):
28 initialize_response = await session.initialize()
29 list_tools_response = await session.list_tools()
30 return initialize_response, list_tools_response
31
32
33 def trim_schema(schema: dict) -> dict:
34 # Turn JSON Schema from MCP generated into Harmony's variant.
35 if "title" in schema:
36 del schema["title"]
37 if "default" in schema and schema["default"] is None:
38 del schema["default"]
39 if "anyOf" in schema:
40 # Turn "anyOf": [{"type": "type-1"}, {"type": "type-2"}]
41 # into "type": ["type-1", "type-2"]
42 # if there's more than 1 types, also remove "null" type as Harmony will
43 # just ignore it
44 types = [
45 type_dict["type"]
46 for type_dict in schema["anyOf"]
47 if type_dict["type"] != "null"
48 ]
49 schema["type"] = types
50 del schema["anyOf"]
51 if "properties" in schema:
52 schema["properties"] = {
53 k: trim_schema(v) for k, v in schema["properties"].items()
54 }
55 return schema
56
57
58 def post_process_tools_description(
59 list_tools_result: "ListToolsResult",
60 ) -> "ListToolsResult":
61 # Adapt the MCP tool result for Harmony
62 for tool in list_tools_result.tools:
63 tool.input_schema = trim_schema(tool.input_schema)
64
65 # Some tools schema don't need to be part of the prompt (e.g. simple text
66 # in text out for Python)
67 list_tools_result.tools = [
68 tool
69 for tool in list_tools_result.tools
70 if (tool.meta or {}).get("include_in_prompt", True)
71 ]
72
73 return list_tools_result
74
75
76 class ToolServer(ABC):
77 @abstractmethod
78 def has_tool(self, tool_name: str) -> bool:
79 """Return True if the tool is supported, False otherwise."""
80 pass
81
82 @abstractmethod
83 def get_tool_description(
84 self, tool_name: str, allowed_tools: list[str] | None = None
85 ) -> ToolNamespaceConfig | None:
86 """Return the tool description for the given tool name.
87 If the tool is not supported, return None.
88 """
89 pass
90
91 @abstractmethod
92 def new_session(
93 self, tool_name: str, session_id: str, headers: dict[str, str] | None = None
94 ) -> AbstractAsyncContextManager[Any]:
95 """Create a session for the tool."""
96 ...
97
98
99 class MCPToolServer(ToolServer):
100 def __init__(self):
101 try:
102 import mcp # noqa: F401
103 except ImportError:
104 raise ImportError(
105 "mcp is not installed. Please run `pip install mcp` to use "
106 "MCPToolServer."
107 ) from None
108 self.harmony_tool_descriptions = {}
109
110 async def add_tool_server(self, server_url: str):
111 tool_urls = server_url.split(",")
112 self.harmony_tool_descriptions = {}
113 self.urls: dict[str, str] = {}
114 for url in tool_urls:
115 url = f"http://{url}/sse"
116 initialize_response, list_tools_response = await list_server_and_tools(url)
117
118 list_tools_response = post_process_tools_description(list_tools_response)
119
120 tool_from_mcp = ToolNamespaceConfig(
121 name=initialize_response.server_info.name,
122 description=initialize_response.instructions,
123 tools=[
124 ToolDescription.new(
125 name=tool.name,
126 description=tool.description,
127 parameters=tool.input_schema,
128 )
129 for tool in list_tools_response.tools
130 ],
131 )
132 self.harmony_tool_descriptions[tool_from_mcp.name] = tool_from_mcp
133 if tool_from_mcp.name not in self.urls:
134 self.urls[tool_from_mcp.name] = url
135 else:
136 logger.warning(
137 "Tool %s already exists. Ignoring duplicate tool server %s",
138 tool_from_mcp.name,
139 url,
140 )
141 logger.info(
142 "MCPToolServer initialized with tools: %s",
143 list(self.harmony_tool_descriptions.keys()),
144 )
145
146 def has_tool(self, tool_name: str):
147 return tool_name in self.harmony_tool_descriptions
148
149 def get_tool_description(
150 self,
151 server_label: str,
152 allowed_tools: list[str] | None = None,
153 ) -> ToolNamespaceConfig | None:
154 cfg = self.harmony_tool_descriptions.get(server_label)
155 if cfg is None:
156 return None
157
158 # No restrictions: all tools from this MCP server
159 if allowed_tools is None:
160 return cfg
161
162 filtered = [t for t in cfg.tools if t.name in allowed_tools]
163
164 if not filtered:
165 return None
166
167 return ToolNamespaceConfig(
168 name=cfg.name,
169 description=cfg.description,
170 tools=filtered,
171 )
172
173 @asynccontextmanager
174 async def new_session(
175 self, tool_name: str, session_id: str, headers: dict[str, str] | None = None
176 ):
177 from mcp import ClientSession
178 from mcp.client.sse import sse_client
179
180 url = self.urls.get(tool_name)
181 request_headers = {"x-session-id": session_id}
182 if headers is not None:
183 request_headers.update(headers)
184 if not url:
185 raise KeyError(f"Tool '{tool_name}' is not supported")
186 async with (
187 sse_client(url=url, headers=request_headers) as streams,
188 ClientSession(*streams) as session,
189 ):
190 await session.initialize()
191 yield session
192
193
194 class DemoToolServer(ToolServer):
195 def __init__(self):
196 self.tools: dict[str, Tool] = {}
197
198 async def init_and_validate(self):
199 browser_tool = HarmonyBrowserTool()
200 python_tool = HarmonyPythonTool()
201 await python_tool.validate()
202 if browser_tool.enabled:
203 self.tools["browser"] = browser_tool
204 if python_tool.enabled:
205 self.tools["python"] = python_tool
206 logger.info(
207 "DemoToolServer initialized with tools: %s", list(self.tools.keys())
208 )
209
210 def has_tool(self, tool_name: str) -> bool:
211 return tool_name in self.tools
212
213 def get_tool_description(
214 self, tool_name: str, allowed_tools: list[str] | None = None
215 ) -> ToolNamespaceConfig | None:
216 if tool_name not in self.tools:
217 return None
218 if tool_name == "browser":
219 return ToolNamespaceConfig.browser()
220 elif tool_name == "python":
221 return ToolNamespaceConfig.python()
222 else:
223 raise ValueError(f"Unknown tool {tool_name}")
224
225 @asynccontextmanager
226 async def new_session(
227 self, tool_name: str, session_id: str, headers: dict[str, str] | None = None
228 ):
229 if tool_name not in self.tools:
230 raise KeyError(f"Tool '{tool_name}' is not supported")
231 yield self.tools[tool_name]
232
233
234 async def init_tool_server(args: "Namespace") -> ToolServer | None:
235 tool_server = getattr(args, "tool_server", None)
236 if tool_server == "demo":
237 demo_server = DemoToolServer()
238 await demo_server.init_and_validate()
239 return demo_server
240 if tool_server:
241 mcp_server = MCPToolServer()
242 await mcp_server.add_tool_server(tool_server)
243 return mcp_server
244 return None