mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-28 05:27:35 +00:00
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:
parent
1a32d92d08
commit
91ace557a3
2 changed files with 78 additions and 2 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue