diff --git a/docs/Tool Calling Tutorial.md b/docs/Tool Calling Tutorial.md index 7d2a86de..e8e86da5 100644 --- a/docs/Tool Calling Tutorial.md +++ b/docs/Tool Calling Tutorial.md @@ -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 diff --git a/modules/chat.py b/modules/chat.py index 7ebffef4..a4e4100e 100644 --- a/modules/chat.py +++ b/modules/chat.py @@ -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'] diff --git a/modules/tool_use.py b/modules/tool_use.py index f9ddf940..143ca752 100644 --- a/modules/tool_use.py +++ b/modules/tool_use.py @@ -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 [], {} diff --git a/modules/ui_chat.py b/modules/ui_chat.py index 69f0bbaf..f0a2c6d6 100644 --- a/modules/ui_chat.py +++ b/modules/ui_chat.py @@ -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("") diff --git a/server.py b/server.py index 14a58531..4fada434 100644 --- a/server.py +++ b/server.py @@ -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: