mirror of
https://github.com/oobabooga/text-generation-webui.git
synced 2026-04-20 22:13:43 +00:00
Add stdio MCP server support via user_data/mcp.json
This commit is contained in:
parent
1b3af51dc5
commit
707b5df4ea
5 changed files with 144 additions and 25 deletions
|
|
@ -82,7 +82,9 @@ You can open the built-in tools in `user_data/tools/` for more examples.
|
|||
|
||||
## MCP servers
|
||||
|
||||
You can connect to remote [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) servers to use their tools alongside local ones.
|
||||
You can connect to [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) servers to use their tools alongside local ones. Both HTTP and stdio servers are supported.
|
||||
|
||||
### HTTP servers
|
||||
|
||||
In the chat sidebar, open the **MCP servers** accordion and enter one server URL per line. For servers that require authentication, append headers after the URL separated by commas:
|
||||
|
||||
|
|
@ -91,6 +93,47 @@ https://example.com/mcp
|
|||
https://other.com/mcp,Authorization: Bearer sk-xxx
|
||||
```
|
||||
|
||||
### Stdio servers
|
||||
|
||||
Stdio MCP servers run as local subprocesses. To configure them, create a `user_data/mcp.json` file using the standard format (compatible with Claude Desktop, Cursor, and LM Studio):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/dir"]
|
||||
},
|
||||
"another-server": {
|
||||
"command": "python3",
|
||||
"args": ["-m", "my_mcp_server", "--flag", "value"],
|
||||
"env": {
|
||||
"API_KEY": "your-key-here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The file is detected automatically and a warning is printed at startup when it is found.
|
||||
|
||||
**Quick test example:** Install `npx` (comes with Node.js), then create `user_data/mcp.json` with:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp/folder"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Create the target directory (`mkdir -p /tmp/folder`), start the web UI, load a model with tool-calling support, and try asking: *"What files are in /tmp/folder?"* or *"Write a file called notes.txt in /tmp/folder containing 'MCP is working'"*.
|
||||
|
||||
### Tool priority
|
||||
|
||||
All tools from the configured servers are automatically discovered and made available to the model during generation. If an MCP tool has the same name as a selected local tool, the local tool takes priority.
|
||||
|
||||
## Tool calling over the API
|
||||
|
|
|
|||
|
|
@ -1288,14 +1288,16 @@ def generate_chat_reply_wrapper(text, state, regenerate=False, _continue=False):
|
|||
# Load tools if any are selected
|
||||
selected = state.get('selected_tools', [])
|
||||
mcp_servers = state.get('mcp_servers', '')
|
||||
from modules.tool_use import has_mcp_config
|
||||
has_mcp = has_mcp_config()
|
||||
parse_tool_call = None
|
||||
_tool_parsers = None
|
||||
if selected or mcp_servers:
|
||||
if selected or mcp_servers or has_mcp:
|
||||
from modules.tool_use import load_tools, load_mcp_tools, execute_tool
|
||||
from modules.tool_parsing import parse_tool_call, get_tool_call_id, detect_tool_call_format
|
||||
|
||||
tool_defs, tool_executors = load_tools(selected)
|
||||
if mcp_servers:
|
||||
if mcp_servers or has_mcp:
|
||||
mcp_defs, mcp_executors = load_mcp_tools(mcp_servers)
|
||||
for td in mcp_defs:
|
||||
fn = td['function']['name']
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ from modules import shared
|
|||
from modules.logging_colors import logger
|
||||
from modules.utils import natural_keys, sanitize_filename
|
||||
|
||||
_MCP_JSON_PATH = shared.user_data_dir / 'mcp.json'
|
||||
|
||||
|
||||
def get_available_tools():
|
||||
"""Return sorted list of tool script names from user_data/tools/*.py."""
|
||||
|
|
@ -57,7 +59,7 @@ def load_tools(selected_names):
|
|||
|
||||
|
||||
def _parse_mcp_servers(servers_str):
|
||||
"""Parse MCP servers textbox: one server per line, format 'url' or 'url,Header: value,Header2: value2'."""
|
||||
"""Parse MCP servers textbox: one HTTP server per line, format 'url' or 'url,Header: value,Header2: value2'."""
|
||||
servers = []
|
||||
for line in servers_str.strip().splitlines():
|
||||
line = line.strip()
|
||||
|
|
@ -71,7 +73,53 @@ def _parse_mcp_servers(servers_str):
|
|||
if ':' in part:
|
||||
key, val = part.split(':', 1)
|
||||
headers[key.strip()] = val.strip()
|
||||
servers.append((url, headers))
|
||||
servers.append({"type": "http", "url": url, "headers": headers})
|
||||
return servers
|
||||
|
||||
|
||||
def has_mcp_config():
|
||||
"""Check if user_data/mcp.json exists."""
|
||||
return _MCP_JSON_PATH.exists()
|
||||
|
||||
|
||||
def _load_mcp_json():
|
||||
"""Load stdio MCP servers from user_data/mcp.json (Claude Desktop / Cursor format).
|
||||
|
||||
Expected format:
|
||||
{
|
||||
"mcpServers": {
|
||||
"server-name": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path"],
|
||||
"env": {"KEY": "value"}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
if not _MCP_JSON_PATH.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(_MCP_JSON_PATH) as f:
|
||||
config = json.load(f)
|
||||
except Exception:
|
||||
logger.exception(f'Failed to parse {_MCP_JSON_PATH}')
|
||||
return []
|
||||
|
||||
servers = []
|
||||
for name, entry in config.get('mcpServers', {}).items():
|
||||
command = entry.get('command')
|
||||
if not command:
|
||||
logger.warning(f'MCP server "{name}" in mcp.json is missing "command". Skipping.')
|
||||
continue
|
||||
|
||||
servers.append({
|
||||
"type": "stdio",
|
||||
"command": command,
|
||||
"args": entry.get("args", []),
|
||||
"env": entry.get("env"),
|
||||
})
|
||||
|
||||
return servers
|
||||
|
||||
|
||||
|
|
@ -87,24 +135,45 @@ def _mcp_tool_to_openai(tool):
|
|||
}
|
||||
|
||||
|
||||
async def _mcp_session(url, headers, callback):
|
||||
def _mcp_server_id(server):
|
||||
"""Return a human-readable identifier for a server config."""
|
||||
if server["type"] == "http":
|
||||
return server["url"]
|
||||
elif server["type"] == "stdio":
|
||||
return f'{server["command"]} {" ".join(server["args"])}'
|
||||
else:
|
||||
raise ValueError(f"Unknown MCP server type: {server['type']}")
|
||||
|
||||
|
||||
async def _mcp_session(server, callback):
|
||||
"""Open an MCP session and pass it to the callback."""
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from mcp import ClientSession
|
||||
|
||||
async with streamablehttp_client(url, headers=headers or None) as (read_stream, write_stream, _):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
return await callback(session)
|
||||
if server["type"] == "http":
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
async with streamablehttp_client(server["url"], headers=server["headers"] or None) as (read_stream, write_stream, _):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
return await callback(session)
|
||||
elif server["type"] == "stdio":
|
||||
from mcp import StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
params = StdioServerParameters(command=server["command"], args=server["args"], env=server.get("env"))
|
||||
async with stdio_client(params) as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
return await callback(session)
|
||||
else:
|
||||
raise ValueError(f"Unknown MCP server type: {server['type']}")
|
||||
|
||||
|
||||
def _make_mcp_executor(name, url, headers):
|
||||
def _make_mcp_executor(name, server):
|
||||
def executor(arguments):
|
||||
return asyncio.run(_call_mcp_tool(name, arguments, url, headers))
|
||||
return asyncio.run(_call_mcp_tool(name, arguments, server))
|
||||
return executor
|
||||
|
||||
|
||||
async def _connect_mcp_server(url, headers):
|
||||
async def _connect_mcp_server(server):
|
||||
"""Connect to one MCP server and return (tool_defs, executors)."""
|
||||
|
||||
async def _discover(session):
|
||||
|
|
@ -113,13 +182,13 @@ async def _connect_mcp_server(url, headers):
|
|||
executors = {}
|
||||
for tool in result.tools:
|
||||
tool_defs.append(_mcp_tool_to_openai(tool))
|
||||
executors[tool.name] = _make_mcp_executor(tool.name, url, headers)
|
||||
executors[tool.name] = _make_mcp_executor(tool.name, server)
|
||||
return tool_defs, executors
|
||||
|
||||
return await _mcp_session(url, headers, _discover)
|
||||
return await _mcp_session(server, _discover)
|
||||
|
||||
|
||||
async def _call_mcp_tool(name, arguments, url, headers):
|
||||
async def _call_mcp_tool(name, arguments, server):
|
||||
"""Connect to an MCP server and call a single tool."""
|
||||
|
||||
async def _invoke(session):
|
||||
|
|
@ -132,25 +201,25 @@ async def _call_mcp_tool(name, arguments, url, headers):
|
|||
parts.append(str(content))
|
||||
return '\n'.join(parts) if parts else ''
|
||||
|
||||
return await _mcp_session(url, headers, _invoke)
|
||||
return await _mcp_session(server, _invoke)
|
||||
|
||||
|
||||
async def _connect_all_mcp_servers(servers):
|
||||
"""Connect to all MCP servers concurrently."""
|
||||
results = await asyncio.gather(
|
||||
*(_connect_mcp_server(url, headers) for url, headers in servers),
|
||||
*(_connect_mcp_server(server) for server in servers),
|
||||
return_exceptions=True
|
||||
)
|
||||
all_defs = []
|
||||
all_executors = {}
|
||||
for (url, _), result in zip(servers, results):
|
||||
for server, result in zip(servers, results):
|
||||
if isinstance(result, Exception):
|
||||
logger.exception(f'Failed to connect to MCP server "{url}"', exc_info=result)
|
||||
logger.exception(f'Failed to connect to MCP server "{_mcp_server_id(server)}"', exc_info=result)
|
||||
continue
|
||||
defs, execs = result
|
||||
for td, (fn, ex) in zip(defs, execs.items()):
|
||||
if fn in all_executors:
|
||||
logger.warning(f'MCP tool "{fn}" from {url} conflicts with an already loaded tool. Skipping.')
|
||||
logger.warning(f'MCP tool "{fn}" from {_mcp_server_id(server)} conflicts with an already loaded tool. Skipping.')
|
||||
continue
|
||||
all_defs.append(td)
|
||||
all_executors[fn] = ex
|
||||
|
|
@ -159,10 +228,11 @@ async def _connect_all_mcp_servers(servers):
|
|||
|
||||
def load_mcp_tools(servers_str):
|
||||
"""
|
||||
Parse MCP servers string and discover tools from each server.
|
||||
Discover tools from MCP servers (HTTP from UI textbox + stdio from mcp.json).
|
||||
Returns (tool_defs, executors) in the same format as load_tools.
|
||||
"""
|
||||
servers = _parse_mcp_servers(servers_str)
|
||||
servers = _parse_mcp_servers(servers_str) if servers_str else []
|
||||
servers += _load_mcp_json()
|
||||
if not servers:
|
||||
return [], {}
|
||||
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ def create_ui():
|
|||
shared.gradio['selected_tools'].change(fn=sync_web_tools, inputs=[shared.gradio['selected_tools']], outputs=[shared.gradio['selected_tools']], show_progress=False)
|
||||
|
||||
with gr.Accordion('MCP servers', open=False):
|
||||
shared.gradio['mcp_servers'] = gr.Textbox(value=shared.settings.get('mcp_servers', ''), lines=3, max_lines=3, label='', info='One url per line. For headers, write url,Header: value,Header2: value2', elem_classes=['add_scrollbar'])
|
||||
shared.gradio['mcp_servers'] = gr.Textbox(value=shared.settings.get('mcp_servers', ''), lines=3, max_lines=3, label='', info='One URL per line for HTTP servers. For headers: url,Header: value. For stdio servers, use user_data/mcp.json.', elem_classes=['add_scrollbar'])
|
||||
|
||||
gr.HTML("<div class='sidebar-vertical-separator'></div>")
|
||||
|
||||
|
|
|
|||
|
|
@ -259,6 +259,10 @@ if __name__ == "__main__":
|
|||
elif (shared.user_data_dir / 'settings.yaml').exists():
|
||||
settings_file = shared.user_data_dir / 'settings.yaml'
|
||||
|
||||
from modules.tool_use import has_mcp_config
|
||||
if has_mcp_config():
|
||||
logger.warning(f"MCP stdio servers will be loaded from \"{shared.user_data_dir / 'mcp.json'}\"")
|
||||
|
||||
if settings_file is not None:
|
||||
logger.info(f"Loading settings from \"{settings_file}\"")
|
||||
with open(settings_file, 'r', encoding='utf-8') as f:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue