From 91ace557a33523cce2cd8f27bd1ff387b41cb1f2 Mon Sep 17 00:00:00 2001 From: Pedro Machado Date: Fri, 17 Jul 2026 16:20:09 +0100 Subject: [PATCH] feat: add MCP elicitation support for server-enforced user confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add elicitation_callback to MCPClient that shows native ConfirmDialog when MCP servers call ctx.elicit(). Users must approve/reject before write operations execute — server-enforced, LLM cannot bypass. Changes: - client.py: _elicitation_callback using event_caller, capability gating, URL-mode handling, schema-aware responses, instructions capture - middleware.py: wire event_caller to MCPClient, inject server instructions with sanitized labeled blocks Safe defaults: decline on errors/timeouts, capability only advertised for interactive WebSocket sessions. Discussion: https://github.com/open-webui/open-webui/discussions/27156 --- backend/open_webui/utils/mcp/client.py | 64 +++++++++++++++++++++++++- backend/open_webui/utils/middleware.py | 16 +++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/backend/open_webui/utils/mcp/client.py b/backend/open_webui/utils/mcp/client.py index 39b7ae3b8e..32b775cac5 100644 --- a/backend/open_webui/utils/mcp/client.py +++ b/backend/open_webui/utils/mcp/client.py @@ -11,6 +11,7 @@ from mcp import ClientSession from mcp.client.auth import OAuthClientProvider, TokenStorage from mcp.client.streamable_http import streamablehttp_client from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken +from mcp.types import ElicitRequestURLParams, ElicitResult from open_webui.env import ( AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER, @@ -60,6 +61,60 @@ class MCPClient: def __init__(self): self.session: Optional[ClientSession] = None self.exit_stack = None + self.instructions: Optional[str] = None + self._event_caller = None + + async def _elicitation_callback(self, context, params): + """Handle MCP elicitation requests from server tools. + + Uses OWUI's event_caller to show a confirmation dialog to the user + and waits for their response. + """ + message = getattr(params, 'message', 'Action confirmation requested') + log.debug('MCP elicitation callback: %s', message[:200]) + + try: + response = await self._event_caller( + { + 'type': 'confirmation', + 'data': { + 'title': '⚠️ Action Confirmation', + 'message': message, + }, + } + ) + if isinstance(response, dict) and response.get('error'): + log.warning('MCP elicitation event_caller error: %s', response['error']) + return ElicitResult(action='cancel', content=None) + if response: + log.debug('MCP elicitation approved by user') + # URL-mode elicitation: content must be omitted + if isinstance(params, ElicitRequestURLParams): + return ElicitResult(action='accept', content=None) + # Form-mode: build content matching the requestedSchema + schema = getattr(params, 'requestedSchema', None) + if schema and isinstance(schema, dict): + props = schema.get('properties', {}) + content = {} + for key, prop in props.items(): + prop_type = prop.get('type', 'boolean') + if prop_type == 'boolean': + content[key] = True + elif prop_type == 'string': + content[key] = 'confirmed' + elif prop_type in ('number', 'integer'): + content[key] = 1 + else: + content[key] = True + else: + content = {'value': True} + return ElicitResult(action='accept', content=content) + else: + log.debug('MCP elicitation rejected by user') + return ElicitResult(action='decline', content=None) + except Exception as e: + log.warning('MCP elicitation event_caller failed: %s', e) + return ElicitResult(action='decline', content=None) async def connect(self, url: str, headers: Optional[dict] = None): async with AsyncExitStack() as exit_stack: @@ -75,11 +130,16 @@ class MCPClient: transport = await exit_stack.enter_async_context(self._streams_context) read_stream, write_stream, _ = transport - self._session_context = ClientSession(read_stream, write_stream) # pylint: disable=W0201 + self._session_context = ClientSession( + read_stream, + write_stream, + elicitation_callback=self._elicitation_callback if self._event_caller else None, + ) self.session = await exit_stack.enter_async_context(self._session_context) with anyio.fail_after(MCP_INITIALIZE_TIMEOUT): - await self.session.initialize() + init_result = await self.session.initialize() + self.instructions = getattr(init_result, 'instructions', None) self.exit_stack = exit_stack.pop_all() except Exception as e: await self.disconnect() diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index a1a2a432f5..13a583d0f4 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -2195,6 +2195,7 @@ async def connect_mcp_server( ) client = MCPClient() + client._event_caller = extra_params.get('__event_call__') await client.connect( url=mcp_server_connection.get('url', ''), headers=headers if headers else None, @@ -2707,6 +2708,21 @@ async def process_chat_payload(request, form_data, user, metadata, model): client, tool_specs = result mcp_clients[server_id] = client + if client.instructions: + # Sanitize to prevent label-breakout injection + safe_server_id = server_id.replace(']', '').replace('\n', '') + safe_instructions = client.instructions.replace( + '[/MCP_SERVER_INSTRUCTIONS', '[/MCP\\_SERVER\\_INSTRUCTIONS' + ).replace('[MCP_SERVER_INSTRUCTIONS', '[MCP\\_SERVER\\_INSTRUCTIONS') + labeled_instructions = ( + f'[MCP_SERVER_INSTRUCTIONS: {safe_server_id}]\n' + f'{safe_instructions}\n' + f'[/MCP_SERVER_INSTRUCTIONS: {safe_server_id}]' + ) + form_data['messages'] = add_or_update_system_message( + labeled_instructions, form_data['messages'], append=True + ) + for tool_spec in tool_specs: async def make_tool_function(client, function_name):