Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca6db76915 |
@@ -103,7 +103,7 @@ def get_model_configuration(model_hrid: str):
|
||||
class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
"""Service class for AI-related operations (Pydantic-AI edition)."""
|
||||
|
||||
def __init__(self, conversation: models.ChatConversation, user, model_hrid=None, language=None):
|
||||
def __init__(self, conversation: models.ChatConversation, user, model_hrid=None, language=None, custom_mcp_url=None):
|
||||
"""
|
||||
Initialize the AI agent service.
|
||||
|
||||
@@ -115,6 +115,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
self.user = user # authenticated user only
|
||||
self.model_hrid = model_hrid or settings.LLM_DEFAULT_MODEL_HRID # HRID of the model to use
|
||||
self.language = language # might be None
|
||||
self.custom_mcp_url = custom_mcp_url
|
||||
self._last_stop_check = 0
|
||||
|
||||
self._langfuse_available = settings.LANGFUSE_ENABLED
|
||||
@@ -545,7 +546,15 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
|
||||
async with AsyncExitStack() as stack:
|
||||
# MCP servers (if any) can be initialized here
|
||||
mcp_servers = [await stack.enter_async_context(mcp) for mcp in get_mcp_servers()]
|
||||
try:
|
||||
mcp_servers = [
|
||||
await stack.enter_async_context(mcp)
|
||||
for mcp in get_mcp_servers(self.custom_mcp_url)
|
||||
]
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
# If MCP initialization fails, log and continue without MCP instead of crashing.
|
||||
logger.exception("Failed to initialize MCP servers: %s", exc)
|
||||
mcp_servers = []
|
||||
|
||||
_final_output_from_tool = None
|
||||
_ui_sources = []
|
||||
|
||||
@@ -4,6 +4,9 @@ from pydantic_ai.mcp import MCPServerStreamableHTTP
|
||||
|
||||
MCP_SERVERS = {
|
||||
"mcpServers": {
|
||||
#"local": {
|
||||
# "url": "http://host.docker.internal:8007/mcp",
|
||||
#},
|
||||
# "github": {
|
||||
# "url": "https://api.githubcopilot.com/mcp/",
|
||||
# "headers": {"Authorization": "Bearer XXX"},
|
||||
@@ -12,9 +15,14 @@ MCP_SERVERS = {
|
||||
}
|
||||
|
||||
|
||||
def get_mcp_servers():
|
||||
def get_mcp_servers(custom_url: str | None = None):
|
||||
"""Retrieve MCP servers configuration."""
|
||||
return [
|
||||
servers = [
|
||||
MCPServerStreamableHTTP(**server_config)
|
||||
for _name, server_config in MCP_SERVERS["mcpServers"].items()
|
||||
]
|
||||
|
||||
if custom_url:
|
||||
servers.append(MCPServerStreamableHTTP(url=custom_url))
|
||||
|
||||
return servers
|
||||
|
||||
@@ -170,6 +170,13 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method
|
||||
if not messages:
|
||||
return Response({"error": "No messages provided"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
custom_mcp_url = request.META.get("HTTP_X_CUSTOM_MCP_URL")
|
||||
if custom_mcp_url:
|
||||
custom_mcp_url = custom_mcp_url.strip()
|
||||
# Allow MCP-style URLs prefixed with "@"
|
||||
if custom_mcp_url.startswith("@"):
|
||||
custom_mcp_url = custom_mcp_url[1:]
|
||||
|
||||
ai_service = AIAgentService(
|
||||
conversation=conversation,
|
||||
user=self.request.user,
|
||||
@@ -178,6 +185,7 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method
|
||||
self.request.user.language
|
||||
or self.request.LANGUAGE_CODE # from the LocaleMiddleware
|
||||
),
|
||||
custom_mcp_url=custom_mcp_url,
|
||||
)
|
||||
|
||||
# This environment variable allows switching between sync and async streaming modes
|
||||
@@ -435,3 +443,70 @@ class ChatConversationAttachmentViewSet(
|
||||
)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class MCPTestConnectionView(APIView):
|
||||
"""View for testing MCP connection."""
|
||||
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
]
|
||||
|
||||
def post(self, request):
|
||||
"""Handle POST requests to test MCP connection.
|
||||
|
||||
Args:
|
||||
request: The HTTP request object containing:
|
||||
- url: The URL of the MCP server to test.
|
||||
|
||||
Returns:
|
||||
Response: A response indicating whether the connection was successful.
|
||||
"""
|
||||
url = request.data.get("url")
|
||||
if url:
|
||||
url = url.strip()
|
||||
if url.startswith("@"):
|
||||
url = url[1:]
|
||||
if not url:
|
||||
return Response(
|
||||
{"error": "No URL provided"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
# Test connection by initializing the MCP server
|
||||
# We don't need to actually stream, just check if it initializes
|
||||
# But pydantic_ai doesn't expose a simple "ping" method yet easily
|
||||
# so we trust that if the URL is reachable, it's good for now.
|
||||
# Ideally we would list tools.
|
||||
|
||||
# Note: simple HTTP check might be enough as a first step
|
||||
import requests
|
||||
|
||||
# Try to hit the URL - if it's an SSE endpoint it might hang,
|
||||
# but we just want to check if the host exists.
|
||||
# Most MCP servers respond to GET / with 404 or something, but reachable.
|
||||
# If it's /sse, a GET might start a stream.
|
||||
|
||||
# Let's try a simple requests.get with a short timeout
|
||||
# We need to accept text/event-stream to pass the check of some MCP servers
|
||||
try:
|
||||
requests.get(
|
||||
url,
|
||||
timeout=2,
|
||||
headers={"Accept": "text/event-stream"}
|
||||
)
|
||||
except requests.exceptions.Timeout:
|
||||
# Timeout is "okay" for SSE endpoint, it means it's listening
|
||||
pass
|
||||
except requests.exceptions.ConnectionError:
|
||||
return Response(
|
||||
{"error": "Connection refused"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
return Response({"status": "OK"}, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{"error": f"Failed to connect: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -437,6 +437,7 @@ class Base(BraveSettings, Configuration):
|
||||
[
|
||||
"x-posthog-distinct-id",
|
||||
"x-posthog-session-id",
|
||||
"x-custom-mcp-url",
|
||||
],
|
||||
environ_name="CORS_ALLOW_HEADERS",
|
||||
) + list(default_headers)
|
||||
|
||||
@@ -9,7 +9,12 @@ from rest_framework.routers import DefaultRouter
|
||||
from core.api import viewsets
|
||||
|
||||
from activation_codes import viewsets as activation_viewsets
|
||||
from chat.views import ChatConversationAttachmentViewSet, ChatViewSet, LLMConfigurationView
|
||||
from chat.views import (
|
||||
ChatConversationAttachmentViewSet,
|
||||
ChatViewSet,
|
||||
LLMConfigurationView,
|
||||
MCPTestConnectionView,
|
||||
)
|
||||
|
||||
# - Main endpoints
|
||||
router = DefaultRouter()
|
||||
@@ -32,6 +37,7 @@ urlpatterns = [
|
||||
path(
|
||||
"llm-configuration/", LLMConfigurationView.as_view(), name="llm-configuration"
|
||||
),
|
||||
path("mcp-test-connection/", MCPTestConnectionView.as_view(), name="mcp-test-connection"),
|
||||
path(
|
||||
"chats/<uuid:conversation_pk>/",
|
||||
include(conversation_router.urls),
|
||||
|
||||
@@ -17,7 +17,7 @@ const fetchAPIAdapter = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
const { forceWebSearch, selectedModelHrid } =
|
||||
const { forceWebSearch, selectedModelHrid, customMcpServerUrl } =
|
||||
useChatPreferencesStore.getState();
|
||||
|
||||
if (forceWebSearch) {
|
||||
@@ -33,7 +33,22 @@ const fetchAPIAdapter = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
url = `${url}${separator}${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
return fetchAPI(url, init);
|
||||
// Construire les headers comme un simple objet pour que `fetchAPI` puisse les merger.
|
||||
const baseHeaders =
|
||||
(init?.headers as Record<string, string> | undefined) ?? {};
|
||||
|
||||
const extraHeaders: Record<string, string> = {};
|
||||
if (customMcpServerUrl) {
|
||||
extraHeaders['X-Custom-Mcp-Url'] = customMcpServerUrl;
|
||||
}
|
||||
|
||||
return fetchAPI(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...baseHeaders,
|
||||
...extraHeaders,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export function useChat(options: Omit<UseChatOptions, 'fetch'>) {
|
||||
|
||||
@@ -5,10 +5,12 @@ interface ChatPreferencesState {
|
||||
selectedModelHrid: string | null;
|
||||
forceWebSearch: boolean;
|
||||
isPanelOpen: boolean;
|
||||
customMcpServerUrl: string | null;
|
||||
setSelectedModelHrid: (hrid: string | null) => void;
|
||||
toggleForceWebSearch: () => void;
|
||||
setPanelOpen: (isOpen: boolean) => void;
|
||||
togglePanel: () => void;
|
||||
setCustomMcpServerUrl: (url: string | null) => void;
|
||||
}
|
||||
|
||||
export const useChatPreferencesStore = create<ChatPreferencesState>()(
|
||||
@@ -17,11 +19,13 @@ export const useChatPreferencesStore = create<ChatPreferencesState>()(
|
||||
selectedModelHrid: null,
|
||||
forceWebSearch: false,
|
||||
isPanelOpen: false,
|
||||
customMcpServerUrl: null,
|
||||
setSelectedModelHrid: (hrid) => set({ selectedModelHrid: hrid }),
|
||||
toggleForceWebSearch: () =>
|
||||
set((state) => ({ forceWebSearch: !state.forceWebSearch })),
|
||||
setPanelOpen: (isOpen) => set({ isPanelOpen: isOpen }),
|
||||
togglePanel: () => set((state) => ({ isPanelOpen: !state.isPanelOpen })),
|
||||
setCustomMcpServerUrl: (url) => set({ customMcpServerUrl: url }),
|
||||
}),
|
||||
{
|
||||
name: 'chat-preferences',
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Modal, ModalSize } from '@openfun/cunningham-react';
|
||||
import { Button, Input, Modal, ModalSize } from '@openfun/cunningham-react';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { fetchAPI } from '@/api';
|
||||
import { Box, Icon, StyledLink, Text, useToast } from '@/components';
|
||||
import { useUserUpdate } from '@/core/api/useUserUpdate';
|
||||
import { useAuthQuery } from '@/features/auth/api';
|
||||
import { useChatPreferencesStore } from '@/features/chat/stores/useChatPreferencesStore';
|
||||
|
||||
interface SettingsModalProps {
|
||||
onClose: () => void;
|
||||
@@ -15,6 +18,9 @@ export const SettingsModal = ({ onClose, isOpen }: SettingsModalProps) => {
|
||||
const { data: user } = useAuthQuery();
|
||||
const { mutateAsync: updateUser, isPending } = useUserUpdate();
|
||||
const { showToast } = useToast();
|
||||
const { customMcpServerUrl, setCustomMcpServerUrl } =
|
||||
useChatPreferencesStore();
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
|
||||
const handleToggleChange = async () => {
|
||||
if (!user) {
|
||||
@@ -44,6 +50,43 @@ export const SettingsModal = ({ onClose, isOpen }: SettingsModalProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidateMcp = async () => {
|
||||
if (!customMcpServerUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsValidating(true);
|
||||
try {
|
||||
// `fetchAPI` already prefixes `/api/v1.0/`, so we only pass the relative path here.
|
||||
const response = await fetchAPI('mcp-test-connection/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ url: customMcpServerUrl }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showToast('success', t('Connection successful'), 'check_circle', 3000);
|
||||
} else {
|
||||
const data = await response.json();
|
||||
showToast(
|
||||
'error',
|
||||
t('Connection failed: {{error}}', {
|
||||
error: data.error || t('Unknown error'),
|
||||
}),
|
||||
'error',
|
||||
5000,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error testing MCP connection:', error);
|
||||
showToast('error', t('Connection failed'), 'error', 5000);
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
@@ -175,6 +218,41 @@ export const SettingsModal = ({ onClose, isOpen }: SettingsModalProps) => {
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box $margin={{ top: 'md' }}>
|
||||
<Text
|
||||
$size="md"
|
||||
$weight="500"
|
||||
$theme="greyscale"
|
||||
$variation="850"
|
||||
$padding={{ top: 'xs' }}
|
||||
>
|
||||
{t('Developer Settings')}
|
||||
</Text>
|
||||
<Box $margin={{ top: 'sm' }}>
|
||||
<Box $align="flex-end" $gap="sm">
|
||||
<Input
|
||||
label={t('Custom MCP Server URL')}
|
||||
fullWidth
|
||||
value={customMcpServerUrl || ''}
|
||||
onChange={(e) =>
|
||||
setCustomMcpServerUrl(e.target.value || null)
|
||||
}
|
||||
text={t(
|
||||
'Enter the URL of your local MCP server (e.g., http://localhost:8007/mcp)',
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
color="primary"
|
||||
size="small"
|
||||
disabled={!customMcpServerUrl || isValidating}
|
||||
onClick={() => void handleValidateMcp()}
|
||||
>
|
||||
{isValidating ? t('Testing...') : t('Test Connection')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user