feat: add MCP elicitation support for server-enforced user confirmation

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
This commit is contained in:
Pedro Machado 2026-07-17 16:20:09 +01:00
parent 1a32d92d08
commit 91ace557a3
2 changed files with 78 additions and 2 deletions

View file

@ -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()

View file

@ -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):