This commit is contained in:
Timothy Jaeryang Baek 2026-10-05 10:56:06 +04:00
parent cf5755f949
commit fb741ebcd2
7 changed files with 240 additions and 107 deletions

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import logging
import re
import time
@ -44,7 +45,8 @@ from open_webui.utils.plugin import (
replace_imports,
resolve_valves_schema_options,
)
from open_webui.utils.tools import get_tool_servers, get_tool_specs
from open_webui.utils.tools import connect_mcp_server, get_tool_servers
from open_webui.utils.tools import get_tool_specs as get_local_tool_specs
from pydantic import BaseModel, HttpUrl
from sqlalchemy.ext.asyncio import AsyncSession
@ -196,6 +198,32 @@ async def get_tools(
return tools
@router.get('/id/{id}/specs')
async def get_tool_specs(request: Request, id: str, user=Depends(get_verified_user)):
"""Discover tools for an accessible connection. Currently supports MCP."""
if not id.startswith('server:mcp:'):
raise HTTPException(status_code=404, detail='Tool not found')
try:
# Keep connect, discovery and cleanup in one task for the MCP transport.
async with asyncio.timeout(15):
result = await connect_mcp_server(request, id.removeprefix('server:mcp:'), user, {}, {})
if result is None:
raise HTTPException(status_code=404, detail='Tool not found')
client, specs = result
try:
return {'specs': [{'name': spec['name'], 'description': spec.get('description', '')} for spec in specs]}
finally:
await client.disconnect()
except HTTPException:
raise
except TimeoutError:
raise HTTPException(status_code=504, detail='Tool discovery timed out')
except Exception:
log.exception('Failed to discover tool specs')
raise HTTPException(status_code=502, detail='Unable to load tools')
############################
# GetToolList
############################
@ -397,7 +425,7 @@ async def create_new_tools(
TOOLS = get_tools_cache(request)
TOOLS[form_data.id] = tool_module
specs = get_tool_specs(TOOLS[form_data.id])
specs = get_local_tool_specs(TOOLS[form_data.id])
tools = await Tools.insert_new_tool(user.id, form_data, specs, db=db)
tool_cache_dir = CACHE_DIR / 'tools' / form_data.id
@ -539,7 +567,7 @@ async def update_tools_by_id(
TOOLS = get_tools_cache(request)
TOOLS[id] = tool_module
specs = get_tool_specs(TOOLS[id])
specs = get_local_tool_specs(TOOLS[id])
form_data.access_grants = await filter_allowed_access_grants(
await Config.get('user.permissions'),

View file

@ -83,7 +83,7 @@ from open_webui.socket.main import (
get_event_emitter,
)
from open_webui.tasks import clear_response_stream, save_response_stream
from open_webui.utils.access_control import has_connection_access, has_permission
from open_webui.utils.access_control import has_permission
from open_webui.utils.access_control.files import get_owner_accessible_folder_files
from open_webui.utils.access_control.folders import has_folder_access
from open_webui.utils.ask_user import stage_ask_user_tool_calls
@ -104,7 +104,6 @@ from open_webui.utils.filter import (
process_filter_functions,
)
from open_webui.utils.json_codec import JSONCodec
from open_webui.utils.mcp.client import MCPClient
from open_webui.utils.memory import add_memory_context, review_memory_after_turn
from open_webui.utils.misc import (
add_or_update_system_message,
@ -122,7 +121,6 @@ from open_webui.utils.misc import (
get_response_error_detail,
get_system_message,
is_raster_image_content_type,
is_string_allowed,
merge_system_messages,
prepend_to_first_user_message_content,
replace_system_message_content,
@ -145,7 +143,7 @@ from open_webui.utils.task import (
tools_function_calling_generation_template,
)
from open_webui.utils.tools import (
build_tool_server_headers,
connect_mcp_server,
get_attached_knowledge,
get_builtin_tools,
get_terminal_tools,
@ -2320,61 +2318,6 @@ def sanitize_tool_pairs(messages: list[dict]) -> list[dict]:
return sanitized
async def connect_mcp_server(
request,
server_id: str,
user,
metadata: dict,
extra_params: dict,
) -> tuple[MCPClient, list[dict]] | None:
"""Resolve an MCP server connection, authenticate, and return (client, tool_specs).
Returns None if the server is not found or access is denied.
"""
if not ENABLE_TOOL_SERVERS:
log.debug('MCP resolution skipped: external plugins are disabled')
return None
mcp_server_connection = None
for server_connection in await Config.get('tool_server.connections', []):
if server_connection.get('type', '') == 'mcp' and (server_connection.get('info') or {}).get('id') == server_id:
mcp_server_connection = server_connection
break
if not mcp_server_connection:
log.error(f'MCP server with id {server_id} not found')
return None
if not await has_connection_access(user, mcp_server_connection):
log.warning(f'Access denied to MCP server {server_id} for user {user.id}')
return None
headers, _ = await build_tool_server_headers(
mcp_server_connection,
request,
user,
server_id=server_id,
metadata=metadata,
extra_params=extra_params,
)
client = MCPClient()
await client.connect(
url=mcp_server_connection.get('url', ''),
headers=headers if headers else None,
)
function_name_filter_list = mcp_server_connection.get('config', {}).get('function_name_filter_list', '')
if isinstance(function_name_filter_list, str):
function_name_filter_list = function_name_filter_list.split(',')
tool_specs = await client.list_tool_specs()
if function_name_filter_list:
tool_specs = [spec for spec in tool_specs if is_string_allowed(spec['name'], function_name_filter_list)]
return client, tool_specs
async def process_chat_payload(request, form_data, user, metadata, model):
# Ensure chat_id is always a string — external API clients may omit it.
if not isinstance(metadata.get('chat_id'), str):

View file

@ -21,7 +21,7 @@ from urllib.parse import quote, urlencode
import aiohttp
import yaml
from fastapi import Request
from fastapi import HTTPException, Request
from langchain_core.utils.function_calling import (
convert_to_openai_function as convert_pydantic_model_to_openai_function_spec,
)
@ -110,6 +110,7 @@ from open_webui.utils.headers import (
normalize_bearer_token,
)
from open_webui.utils.json_codec import JSONCodec
from open_webui.utils.mcp.client import MCPClient
from open_webui.utils.misc import is_string_allowed
from open_webui.utils.plugin import get_tool_contents_cache, get_tools_cache, load_tool_module_by_id
from open_webui.utils.terminals import (
@ -185,6 +186,75 @@ async def build_tool_server_headers(
return headers, cookies
async def connect_mcp_server(
request,
server_id: str,
user,
metadata: dict,
extra_params: dict,
) -> tuple[MCPClient, list[dict]] | None:
"""Resolve an MCP server connection, authenticate, and return (client, tool_specs).
Returns None if the server is not found or access is denied.
"""
if not ENABLE_TOOL_SERVERS:
log.debug('MCP resolution skipped: external plugins are disabled')
return None
mcp_server_connection = None
for server_connection in await Config.get('tool_server.connections', []):
if server_connection.get('type', '') == 'mcp' and (server_connection.get('info') or {}).get('id') == server_id:
mcp_server_connection = server_connection
break
if not mcp_server_connection or not (mcp_server_connection.get('config') or {}).get('enable'):
log.error(f'MCP server with id {server_id} not found')
return None
if not await has_connection_access(user, mcp_server_connection):
log.warning(f'Access denied to MCP server {server_id} for user {user.id}')
return None
if mcp_server_connection.get('auth_type') == 'system_oauth' and not extra_params.get('__oauth_token__'):
session_id = request.cookies.get('oauth_session_id')
if session_id:
extra_params = {
**extra_params,
'__oauth_token__': await request.app.state.oauth_manager.get_oauth_token(user.id, session_id),
}
headers, _ = await build_tool_server_headers(
mcp_server_connection,
request,
user,
server_id=server_id,
metadata=metadata,
extra_params=extra_params,
)
if mcp_server_connection.get('auth_type') in ('oauth_2.1', 'oauth_2.1_static') and not headers.get('Authorization'):
raise HTTPException(status_code=401, detail='Auth required')
client = MCPClient()
try:
await client.connect(
url=mcp_server_connection.get('url', ''),
headers=headers if headers else None,
)
function_name_filter_list = (mcp_server_connection.get('config') or {}).get('function_name_filter_list', '')
if isinstance(function_name_filter_list, str):
function_name_filter_list = function_name_filter_list.split(',')
tool_specs = await client.list_tool_specs()
if function_name_filter_list:
tool_specs = [spec for spec in tool_specs if is_string_allowed(spec['name'], function_name_filter_list)]
return client, tool_specs
except BaseException:
# MCP sessions must be closed in the same task that opened them.
await client.disconnect()
raise
# Let no function be called without need, and let what
# it yields justify the cost of running it.
async def get_async_tool_function_and_apply_extra_params(
@ -271,9 +341,7 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr
return {}
enabled_ids = [
tool_id
for tool_id in tool_ids
if (ENABLE_TOOL_SERVERS if tool_id.startswith('server:') else ENABLE_TOOLS)
tool_id for tool_id in tool_ids if (ENABLE_TOOL_SERVERS if tool_id.startswith('server:') else ENABLE_TOOLS)
]
if len(enabled_ids) != len(tool_ids):
log.debug('Excluded tools disabled by plugin configuration')

View file

@ -99,6 +99,16 @@ export const getTools = async (token: string = '', query: string | null = null)
return res;
};
export const getToolSpecs = async (token: string, id: string) => {
const res = await fetch(`${WEBUI_API_BASE_URL}/tools/id/${encodeURIComponent(id)}/specs`, {
headers: { Accept: 'application/json', authorization: `Bearer ${token}` }
});
if (!res.ok) {
throw { status: res.status };
}
return (await res.json()).specs;
};
export const getToolList = async (token: string = '') => {
let error = null;

View file

@ -1640,7 +1640,12 @@
});
</script>
<ToolServersModal bind:show={showTools} {selectedToolIds} />
<ToolServersModal
bind:show={showTools}
{selectedToolIds}
onConnect={(id) =>
oauthRedirectHandler({ id, serverId: id.split(':').at(-1), authType: 'mcp' }, chatInputDraft)}
/>
<SkillsModal bind:show={showSkills} {selectedSkillIds} />
<InputVariablesModal

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { getToolSpecs } from '$lib/apis/tools';
import { resolveLocalizedResource } from '$lib/utils/localizedContent';
import { getContext } from 'svelte';
import { toolServers, tools } from '$lib/stores';
@ -9,26 +10,62 @@
import XMark from '$lib/components/icons/XMark.svelte';
export let show = false;
export let selectedToolIds = [];
export let selectedToolIds: string[] = [];
export let onConnect: (id: string) => void = () => {};
let selectedTools = [];
let discovery: Record<
string,
{ specs?: any[]; loading?: boolean; error?: 'auth' | 'unavailable' }
> = {};
$: selectedTools = ($tools ?? []).filter((tool) => selectedToolIds.includes(tool.id));
const loadSpecs = async (tool: { id: string }) => {
if (discovery[tool.id]?.loading) return;
discovery = { ...discovery, [tool.id]: { loading: true } };
try {
const specs = await getToolSpecs(localStorage.token, tool.id);
discovery = { ...discovery, [tool.id]: { specs } };
} catch (error) {
discovery = {
...discovery,
[tool.id]: {
error:
error && typeof error === 'object' && 'status' in error && error.status === 401
? 'auth'
: 'unavailable'
}
};
}
};
const i18n = getContext('i18n');
const reconnect = (tool: { id: string }) => {
show = false;
onConnect(tool.id);
};
const authStatus = (tool) =>
let selectedTools: any[] = [];
$: selectedTools = (($tools ?? []) as any[]).filter((tool) => selectedToolIds.includes(tool.id));
$: selectedToolServers = (($toolServers ?? []) as any[]).filter((server, idx) =>
selectedToolIds.some((id) => {
if (!id.startsWith('direct_server:')) return false;
const serverId = id.slice('direct_server:'.length);
return !isNaN(parseInt(serverId)) ? parseInt(serverId) === idx : serverId === server?.id;
})
);
const i18n = getContext<any>('i18n');
const authStatus = (tool: { id: string; authenticated?: boolean }) =>
tool?.authenticated === false
? {
label: $i18n.t('Auth required'),
dot: 'bg-amber-500',
pill: 'text-amber-700 dark:text-amber-300'
dot: 'bg-amber-500'
}
: tool?.authenticated === true
? {
label: $i18n.t('Connected'),
dot: 'bg-green-500',
pill: 'text-green-700 dark:text-green-300'
dot: 'bg-green-500'
}
: null;
</script>
@ -49,7 +86,7 @@
</div>
{#if selectedTools.length > 0}
{#if $toolServers.length > 0}
{#if selectedToolServers.length > 0}
<div class=" flex justify-between dark:text-gray-300 px-5 pb-1">
<div class=" text-base font-normal self-center">{$i18n.t('Tools')}</div>
</div>
@ -57,29 +94,24 @@
<div class="px-3 pb-3 w-full flex flex-col justify-center">
<div class=" text-sm dark:text-gray-300 mb-1">
{#each selectedTools as tool}
{@const status = authStatus(tool)}
{@const toolSpecs = tool?.specs ?? []}
{#each selectedTools as tool (tool.id)}
{@const isMcp = tool.id.startsWith('server:mcp:')}
{@const state = discovery[tool.id]}
{@const needsAuth = tool.authenticated === false || state?.error === 'auth'}
{@const status = authStatus(needsAuth ? { ...tool, authenticated: false } : tool)}
{@const toolSpecs = needsAuth ? undefined : isMcp ? state?.specs : tool.specs}
<Collapsible
buttonClassName="w-full mb-1 rounded-lg px-2 py-1.5"
chevron={toolSpecs.length > 0}
disabled={toolSpecs.length === 0}
chevron
onChange={(open: boolean) => {
if (open && isMcp && !needsAuth && !state) loadSpecs(tool);
}}
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 min-w-0">
<div class="text-sm font-normal dark:text-gray-100 text-gray-800 truncate">
{resolveLocalizedResource(tool, $i18n.language)}
</div>
{#if tool?.authenticated === false && status}
<span class="text-[0.6875rem] {status.pill} shrink-0">{status.label}</span>
{/if}
{#if toolSpecs.length > 0}
<span
class="inline-flex min-w-3 items-center justify-center text-center text-[0.6875rem] leading-none text-gray-500 dark:text-gray-400 shrink-0"
>
{toolSpecs.length}
</span>
{/if}
{#if status}
<Tooltip content={status.label} className="flex shrink-0 p-1 -m-1">
<span
@ -89,22 +121,59 @@
></span>
</Tooltip>
{/if}
{#if needsAuth}
<span class="text-[0.6875rem] text-amber-700 dark:text-amber-300 shrink-0"
>{$i18n.t('Auth required')}</span
>
{:else if toolSpecs !== undefined}
<span class="text-[0.6875rem] text-gray-500 dark:text-gray-400 shrink-0">
{toolSpecs.length === 1
? $i18n.t('1 tool')
: $i18n.t('{{COUNT}} tools', { COUNT: toolSpecs.length })}
</span>
{/if}
</div>
{#if resolveLocalizedResource(tool, $i18n.language, 'description')}
<div class="text-xs text-gray-500 truncate">
{resolveLocalizedResource(tool, $i18n.language, 'description')}
</div>
{/if}
</div>
<div slot="content" class="pl-4 pr-2 pb-2 text-xs text-gray-500 dark:text-gray-400">
{#if toolSpecs.length > 0}
{#each toolSpecs as toolSpec}
<div class="mt-1 truncate">
{toolSpec?.name ?? toolSpec?.function?.name}
</div>
{/each}
<div slot="content" class="px-2 pb-2 text-xs text-gray-500 dark:text-gray-400">
{#if needsAuth}
<button
class="my-1 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition underline"
on:click={() => reconnect(tool)}>{$i18n.t('Reconnect')}</button
>
{:else if state?.loading}
<p class="my-1" role="status">{$i18n.t('Loading tools...')}</p>
{:else if state?.error}
<div class="flex items-center justify-between gap-2 my-1" role="status">
<span>{$i18n.t('Unable to load tools')}</span>
<button
class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition underline"
on:click={() => loadSpecs(tool)}>{$i18n.t('Retry')}</button
>
</div>
{:else if toolSpecs?.length > 0}
<div class="max-h-64 space-y-2 overflow-y-auto overscroll-contain py-1">
{#each toolSpecs as toolSpec}
<div class="min-w-0">
<div
class="text-xs font-normal leading-4 text-gray-700 dark:text-gray-300 break-words"
>
{toolSpec?.name ?? toolSpec?.function?.name}
</div>
{#if toolSpec?.description ?? toolSpec?.function?.description}
<div class="text-[0.6875rem] leading-4 text-gray-500 break-words">
{toolSpec?.description ?? toolSpec?.function?.description}
</div>
{/if}
</div>
{/each}
</div>
{:else}
<p class="my-1">{$i18n.t('No tools found')}</p>
{/if}
</div>
</Collapsible>
@ -113,7 +182,7 @@
</div>
{/if}
{#if $toolServers.length > 0}
{#if selectedToolServers.length > 0}
<div class=" flex justify-between dark:text-gray-300 px-5 pb-0.5">
<div class=" text-base font-normal self-center">{$i18n.t('Tool Servers')}</div>
</div>
@ -130,7 +199,7 @@
>
</div>
<div class=" text-sm dark:text-gray-300 mb-1">
{#each $toolServers as toolServer}
{#each selectedToolServers as toolServer}
<Collapsible buttonClassName="w-full" chevron>
<div>
<div class="text-sm font-normal dark:text-gray-100 text-gray-800">
@ -146,14 +215,19 @@
</div>
</div>
<div slot="content">
<div
slot="content"
class="max-h-64 space-y-2 overflow-y-auto overscroll-contain py-1"
>
{#each toolServer?.specs ?? [] as tool_spec}
<div class="my-1">
<div class="font-normal text-gray-800 dark:text-gray-100">
<div class="min-w-0">
<div
class="text-xs font-normal leading-4 text-gray-700 dark:text-gray-300 break-words"
>
{tool_spec?.name}
</div>
<div>
<div class="text-[0.6875rem] leading-4 text-gray-500 break-words">
{tool_spec?.description}
</div>
</div>

View file

@ -1,4 +1,9 @@
{
"1 tool": "",
"{{COUNT}} tools": "",
"Reconnect": "",
"Loading tools...": "",
"Unable to load tools": "",
"-1 for no limit, or a positive integer for a specific limit": "",
"(latest)": "",
"(leave blank for to use commercial endpoint)": "",