Compare commits

...

1 Commits

Author SHA1 Message Date
camilleAND ca6db76915 mcps : testing manual mcp entries 2025-12-04 10:59:39 +01:00
8 changed files with 204 additions and 8 deletions
+11 -2
View File
@@ -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 = []
+10 -2
View File
@@ -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
+75
View File
@@ -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,
)
+1
View File
@@ -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)
+7 -1
View File
@@ -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>
);