feat: finalize remote mcp

This commit is contained in:
Siddhant Rai
2025-09-04 15:10:12 +05:30
parent 7c23f43c63
commit 1bf6af6eeb
11 changed files with 453 additions and 646 deletions

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="64" height="64" color="#000000" fill="none">
<path d="M3.49994 11.7501L11.6717 3.57855C12.7762 2.47398 14.5672 2.47398 15.6717 3.57855C16.7762 4.68312 16.7762 6.47398 15.6717 7.57855M15.6717 7.57855L9.49994 13.7501M15.6717 7.57855C16.7762 6.47398 18.5672 6.47398 19.6717 7.57855C20.7762 8.68312 20.7762 10.474 19.6717 11.5785L12.7072 18.543C12.3167 18.9335 12.3167 19.5667 12.7072 19.9572L13.9999 21.2499" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M17.4999 9.74921L11.3282 15.921C10.2237 17.0255 8.43272 17.0255 7.32823 15.921C6.22373 14.8164 6.22373 13.0255 7.32823 11.921L13.4999 5.74939" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 831 B

View File

@@ -56,6 +56,8 @@ const endpoints = {
DIRECTORY_STRUCTURE: (docId: string) =>
`/api/directory_structure?id=${docId}`,
MANAGE_SOURCE_FILES: '/api/manage_source_files',
MCP_TEST_CONNECTION: '/api/mcp_server/test',
MCP_SAVE_SERVER: '/api/mcp_server/save',
},
CONVERSATION: {
ANSWER: '/api/answer',

View File

@@ -89,7 +89,10 @@ const userService = {
path?: string,
search?: string,
): Promise<any> =>
apiClient.get(endpoints.USER.GET_CHUNKS(docId, page, perPage, path, search), token),
apiClient.get(
endpoints.USER.GET_CHUNKS(docId, page, perPage, path, search),
token,
),
addChunk: (data: any, token: string | null): Promise<any> =>
apiClient.post(endpoints.USER.ADD_CHUNK, data, token),
deleteChunk: (
@@ -104,6 +107,10 @@ const userService = {
apiClient.get(endpoints.USER.DIRECTORY_STRUCTURE(docId), token),
manageSourceFiles: (data: FormData, token: string | null): Promise<any> =>
apiClient.postFormData(endpoints.USER.MANAGE_SOURCE_FILES, data, token),
testMCPConnection: (data: any, token: string | null): Promise<any> =>
apiClient.post(endpoints.USER.MCP_TEST_CONNECTION, data, token),
saveMCPServer: (data: any, token: string | null): Promise<any> =>
apiClient.post(endpoints.USER.MCP_SAVE_SERVER, data, token),
};
export default userService;

View File

@@ -187,47 +187,24 @@
"regularTools": "Regular Tools",
"mcpTools": "MCP Tools",
"mcp": {
"title": "MCP (Model Context Protocol) Servers",
"description": "Connect to remote MCP servers to access their tools and capabilities. Only remote servers are supported.",
"addServer": "Add MCP Server",
"editServer": "Edit Server",
"deleteServer": "Delete Server",
"delete": "Delete",
"serverName": "Server Name",
"serverUrl": "Server URL",
"authType": "Authentication Type",
"apiKey": "API Key",
"headerName": "Header Name",
"bearerToken": "Bearer Token",
"username": "Username",
"password": "Password",
"timeout": "Timeout (seconds)",
"testConnection": "Test Connection",
"testing": "Testing...",
"saving": "Saving...",
"save": "Save",
"cancel": "Cancel",
"backToServers": "← Back to Servers",
"availableTools": "Available Tools",
"refreshTools": "Refresh Tools",
"refreshing": "Refreshing...",
"serverDisabled": "Server is disabled. Enable it to view available tools.",
"noToolsFound": "No tools found on this server.",
"noServersFound": "No MCP servers configured.",
"addFirstServer": "Add your first MCP server to get started.",
"parameters": "Parameters",
"active": "Active",
"inactive": "Inactive",
"noAuth": "No Authentication",
"toggleServer": "Toggle {{serverName}}",
"deleteWarning": "Are you sure you want to delete the MCP server \"{{serverName}}\"? This action cannot be undone.",
"placeholders": {
"serverName": "My MCP Server",
"serverUrl": "https://api.example.com",
"apiKey": "Enter your API key",
"bearerToken": "Enter your bearer token",
"username": "Enter username",
"password": "Enter password"
"apiKey": "Your secret API key",
"bearerToken": "Your secret token",
"username": "Your username",
"password": "Your password"
},
"errors": {
"nameRequired": "Server name is required",

View File

@@ -2,8 +2,8 @@ import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import apiClient from '../api/client';
import userService from '../api/services/userService';
import Dropdown from '../components/Dropdown';
import Input from '../components/Input';
import Spinner from '../components/Spinner';
import { useOutsideAlerter } from '../hooks';
@@ -19,10 +19,10 @@ interface MCPServerModalProps {
}
const authTypes = [
{ value: 'none', label: 'No Authentication' },
{ value: 'api_key', label: 'API Key' },
{ value: 'bearer', label: 'Bearer Token' },
{ value: 'basic', label: 'Basic Authentication' },
{ label: 'No Authentication', value: 'none' },
{ label: 'API Key', value: 'api_key' },
{ label: 'Bearer Token', value: 'bearer' },
// { label: 'Basic Authentication', value: 'basic' },
];
export default function MCPServerModal({
@@ -36,7 +36,7 @@ export default function MCPServerModal({
const modalRef = useRef<HTMLDivElement>(null);
const [formData, setFormData] = useState({
name: server?.name || 'My MCP Server',
name: server?.displayName || 'My MCP Server',
server_url: server?.server_url || '',
auth_type: server?.auth_type || 'none',
api_key: '',
@@ -44,7 +44,7 @@ export default function MCPServerModal({
bearer_token: '',
username: '',
password: '',
timeout: 30,
timeout: server?.timeout || 30,
});
const [loading, setLoading] = useState(false);
@@ -79,15 +79,37 @@ export default function MCPServerModal({
};
const validateForm = () => {
const requiredFields: { [key: string]: boolean } = {
name: !formData.name.trim(),
server_url: !formData.server_url.trim(),
};
const authFieldChecks: { [key: string]: () => void } = {
api_key: () => {
if (!formData.api_key.trim())
newErrors.api_key = t('settings.tools.mcp.errors.apiKeyRequired');
},
bearer: () => {
if (!formData.bearer_token.trim())
newErrors.bearer_token = t('settings.tools.mcp.errors.tokenRequired');
},
basic: () => {
if (!formData.username.trim())
newErrors.username = t('settings.tools.mcp.errors.usernameRequired');
if (!formData.password.trim())
newErrors.password = t('settings.tools.mcp.errors.passwordRequired');
},
};
const newErrors: { [key: string]: string } = {};
Object.entries(requiredFields).forEach(([field, isEmpty]) => {
if (isEmpty)
newErrors[field] = t(
`settings.tools.mcp.errors.${field === 'name' ? 'nameRequired' : 'urlRequired'}`,
);
});
if (!formData.name.trim()) {
newErrors.name = t('settings.tools.mcp.errors.nameRequired');
}
if (!formData.server_url.trim()) {
newErrors.server_url = t('settings.tools.mcp.errors.urlRequired');
} else {
if (formData.server_url.trim()) {
try {
new URL(formData.server_url);
} catch {
@@ -95,22 +117,15 @@ export default function MCPServerModal({
}
}
if (formData.auth_type === 'api_key' && !formData.api_key.trim()) {
newErrors.api_key = t('settings.tools.mcp.errors.apiKeyRequired');
}
const timeoutValue = formData.timeout === '' ? 30 : formData.timeout;
if (
typeof timeoutValue === 'number' &&
(timeoutValue < 1 || timeoutValue > 300)
)
newErrors.timeout = 'Timeout must be between 1 and 300 seconds';
if (formData.auth_type === 'bearer' && !formData.bearer_token.trim()) {
newErrors.bearer_token = t('settings.tools.mcp.errors.tokenRequired');
}
if (formData.auth_type === 'basic') {
if (!formData.username.trim()) {
newErrors.username = t('settings.tools.mcp.errors.usernameRequired');
}
if (!formData.password.trim()) {
newErrors.password = t('settings.tools.mcp.errors.passwordRequired');
}
}
if (authFieldChecks[formData.auth_type])
authFieldChecks[formData.auth_type]();
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
@@ -128,10 +143,9 @@ export default function MCPServerModal({
const config: any = {
server_url: formData.server_url.trim(),
auth_type: formData.auth_type,
timeout: formData.timeout,
timeout: formData.timeout === '' ? 30 : formData.timeout,
};
// Add credentials directly to config for encryption
if (formData.auth_type === 'api_key') {
config.api_key = formData.api_key.trim();
config.api_key_header = formData.header_name.trim() || 'X-API-Key';
@@ -141,59 +155,19 @@ export default function MCPServerModal({
config.username = formData.username.trim();
config.password = formData.password.trim();
}
return config;
};
const testConnection = async () => {
if (!validateForm()) return;
setTesting(true);
setTestResult(null);
try {
// Create a temporary tool to test
const config = buildToolConfig();
const testData = {
name: 'mcp_tool',
displayName: formData.name,
description: 'MCP Server Connection',
config,
actions: [],
status: false,
};
const response = await userService.createTool(testData, token);
const response = await userService.testMCPConnection({ config }, token);
const result = await response.json();
if (response.ok && result.id) {
// Test the connection
try {
const testResponse = await apiClient.post(
`/api/mcp_server/${result.id}/test`,
{},
token,
);
const testData = await testResponse.json();
setTestResult(testData);
// Clean up the temporary tool
await userService.deleteTool({ id: result.id }, token);
} catch (error) {
setTestResult({
success: false,
message: t('settings.tools.mcp.errors.testFailed'),
});
// Clean up the temporary tool
await userService.deleteTool({ id: result.id }, token);
}
} else {
setTestResult({
success: false,
message: t('settings.tools.mcp.errors.testFailed'),
});
}
setTestResult(result);
} catch (error) {
setTestResult({
success: false,
@@ -206,73 +180,32 @@ export default function MCPServerModal({
const handleSave = async () => {
if (!validateForm()) return;
setLoading(true);
try {
const config = buildToolConfig();
const toolData = {
name: 'mcp_tool',
const serverData = {
displayName: formData.name,
description: `MCP Server: ${formData.server_url}`,
config,
actions: [], // Will be populated after tool creation
status: true,
...(server?.id && { id: server.id }),
};
let toolId: string;
const response = await userService.saveMCPServer(serverData, token);
const result = await response.json();
if (server) {
// Update existing server
await userService.updateTool({ id: server.id, ...toolData }, token);
toolId = server.id;
if (response.ok && result.success) {
setTestResult({
success: true,
message: result.message,
});
onServerSaved();
setModalState('INACTIVE');
resetForm();
} else {
// Create new server
const response = await userService.createTool(toolData, token);
const result = await response.json();
toolId = result.id;
setErrors({
general: result.error || t('settings.tools.mcp.errors.saveFailed'),
});
}
// Now fetch the MCP tools and update the actions
try {
const toolsResponse = await apiClient.get(
`/api/mcp_server/${toolId}/tools`,
token,
);
if (toolsResponse.success && toolsResponse.actions) {
// Update the tool with discovered actions (already formatted by backend)
await userService.updateTool(
{
id: toolId,
...toolData,
actions: toolsResponse.actions,
},
token,
);
console.log(
`Successfully discovered and saved ${toolsResponse.actions.length} MCP tools`,
);
// Show success message with tool count
setTestResult({
success: true,
message: `MCP server saved successfully! Discovered ${toolsResponse.actions.length} tools.`,
});
}
} catch (error) {
console.warn(
'Warning: Could not fetch MCP tools immediately after creation:',
error,
);
// Don't fail the save operation if tool discovery fails
}
onServerSaved();
setModalState('INACTIVE');
resetForm();
} catch (error) {
console.error('Error saving MCP server:', error);
setErrors({ general: t('settings.tools.mcp.errors.saveFailed') });
@@ -285,52 +218,52 @@ export default function MCPServerModal({
switch (formData.auth_type) {
case 'api_key':
return (
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{t('settings.tools.mcp.apiKey')}
</label>
<div className="mb-10">
<div className="mt-6">
<Input
name="api_key"
type="text"
className="rounded-md"
value={formData.api_key}
onChange={(e) => handleInputChange('api_key', e.target.value)}
placeholder={t('settings.tools.mcp.placeholders.apiKey')}
borderVariant="thin"
labelBgClassName="bg-white dark:bg-charleston-green-2"
/>
{errors.api_key && (
<p className="mt-1 text-sm text-red-600">{errors.api_key}</p>
)}
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{t('settings.tools.mcp.headerName')}
</label>
<div className="mt-5">
<Input
name="header_name"
type="text"
className="rounded-md"
value={formData.header_name}
onChange={(e) =>
handleInputChange('header_name', e.target.value)
}
placeholder="X-API-Key"
placeholder={t('settings.tools.mcp.headerName')}
borderVariant="thin"
labelBgClassName="bg-white dark:bg-charleston-green-2"
/>
</div>
</div>
);
case 'bearer':
return (
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{t('settings.tools.mcp.bearerToken')}
</label>
<div className="mb-10">
<Input
name="bearer_token"
type="text"
className="rounded-md"
value={formData.bearer_token}
onChange={(e) =>
handleInputChange('bearer_token', e.target.value)
}
placeholder={t('settings.tools.mcp.placeholders.bearerToken')}
borderVariant="thin"
labelBgClassName="bg-white dark:bg-charleston-green-2"
/>
{errors.bearer_token && (
<p className="mt-1 text-sm text-red-600">{errors.bearer_token}</p>
@@ -339,32 +272,32 @@ export default function MCPServerModal({
);
case 'basic':
return (
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{t('settings.tools.mcp.username')}
</label>
<div className="mb-10">
<div className="mt-6">
<Input
name="username"
type="text"
className="rounded-md"
value={formData.username}
onChange={(e) => handleInputChange('username', e.target.value)}
placeholder={t('settings.tools.mcp.placeholders.username')}
placeholder={t('settings.tools.mcp.username')}
borderVariant="thin"
labelBgClassName="bg-white dark:bg-charleston-green-2"
/>
{errors.username && (
<p className="mt-1 text-sm text-red-600">{errors.username}</p>
)}
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{t('settings.tools.mcp.password')}
</label>
<div className="mt-5">
<Input
name="password"
type="text"
className="rounded-md"
value={formData.password}
onChange={(e) => handleInputChange('password', e.target.value)}
placeholder={t('settings.tools.mcp.placeholders.password')}
placeholder={t('settings.tools.mcp.password')}
borderVariant="thin"
labelBgClassName="bg-white dark:bg-charleston-green-2"
/>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password}</p>
@@ -394,17 +327,17 @@ export default function MCPServerModal({
: t('settings.tools.mcp.addServer')}
</h2>
</div>
<div className="flex-1 overflow-auto px-6">
<div className="space-y-6">
<div className="flex-1 px-6">
<div className="space-y-6 py-6">
<div>
<Input
name="name"
type="text"
className="rounded-md"
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
borderVariant="thin"
placeholder={t('settings.tools.mcp.placeholders.serverName')}
placeholder={t('settings.tools.mcp.serverName')}
labelBgClassName="bg-white dark:bg-charleston-green-2"
/>
{errors.name && (
@@ -413,17 +346,17 @@ export default function MCPServerModal({
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{t('settings.tools.mcp.serverUrl')}
</label>
<Input
name="server_url"
type="text"
className="rounded-md"
value={formData.server_url}
onChange={(e) =>
handleInputChange('server_url', e.target.value)
}
placeholder={t('settings.tools.mcp.placeholders.serverUrl')}
placeholder={t('settings.tools.mcp.serverUrl')}
borderVariant="thin"
labelBgClassName="bg-white dark:bg-charleston-green-2"
/>
{errors.server_url && (
<p className="mt-1 text-sm text-red-600">
@@ -432,106 +365,114 @@ export default function MCPServerModal({
)}
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{t('settings.tools.mcp.authType')}
</label>
<select
value={formData.auth_type}
onChange={(e) =>
handleInputChange('auth_type', e.target.value)
}
className="w-full rounded-lg border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
{authTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
<Dropdown
placeholder={t('settings.tools.mcp.authType')}
selectedValue={
authTypes.find((type) => type.value === formData.auth_type)
?.label || null
}
onSelect={(selection: { label: string; value: string }) => {
handleInputChange('auth_type', selection.value);
}}
options={authTypes}
size="w-full"
rounded="3xl"
border="border"
/>
{renderAuthFields()}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{t('settings.tools.mcp.timeout')}
</label>
<Input
name="timeout"
type="number"
className="rounded-md"
value={formData.timeout}
onChange={(e) =>
handleInputChange('timeout', parseInt(e.target.value) || 30)
}
placeholder="30"
onChange={(e) => {
const value = e.target.value;
if (value === '') {
handleInputChange('timeout', '');
} else {
const numValue = parseInt(value);
if (!isNaN(numValue) && numValue >= 1) {
handleInputChange('timeout', numValue);
}
}
}}
placeholder={t('settings.tools.mcp.timeout')}
borderVariant="thin"
labelBgClassName="bg-white dark:bg-charleston-green-2"
/>
{errors.timeout && (
<p className="mt-2 text-sm text-red-600">{errors.timeout}</p>
)}
</div>
{testResult && (
<div
className={`rounded-lg p-4 ${
className={`rounded-md p-5 ${
testResult.success
? 'bg-green-50 text-green-700 dark:bg-green-900 dark:text-green-300'
? 'bg-green-50 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: 'bg-red-50 text-red-700 dark:bg-red-900 dark:text-red-300'
}`}
>
{testResult.message}
</div>
)}
{errors.general && (
<div className="rounded-lg bg-red-50 p-4 text-red-700 dark:bg-red-900 dark:text-red-300">
<div className="rounded-2xl bg-red-50 p-5 text-red-700 dark:bg-red-900 dark:text-red-300">
{errors.general}
</div>
)}
</div>
</div>
<div className="flex justify-between gap-4 px-6 py-4">
<button
onClick={testConnection}
disabled={testing}
className="flex items-center justify-center rounded-lg border border-gray-300 px-4 py-2 text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
{testing ? (
<div className="flex items-center">
<Spinner />
<span className="ml-2">
{t('settings.tools.mcp.testing')}
</span>
</div>
) : (
t('settings.tools.mcp.testConnection')
)}
</button>
<div className="flex gap-2">
<div className="px-6 py-2">
<div className="flex flex-col gap-4 sm:flex-row sm:justify-between">
<button
onClick={() => {
setModalState('INACTIVE');
resetForm();
}}
className="px-4 py-2 text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
onClick={testConnection}
disabled={testing}
className="border-silver dark:border-dim-gray dark:text-light-gray w-full rounded-3xl border px-6 py-2 text-sm font-medium transition-all hover:bg-gray-100 disabled:opacity-50 sm:w-auto dark:hover:bg-[#767183]/50"
>
{t('settings.tools.mcp.cancel')}
</button>
<button
onClick={handleSave}
disabled={loading}
className="bg-purple-30 hover:bg-violets-are-blue flex items-center justify-center rounded-lg px-6 py-2 text-white disabled:opacity-50"
>
{loading ? (
<div className="flex items-center">
<Spinner />
{testing ? (
<div className="flex items-center justify-center">
<Spinner size="small" />
<span className="ml-2">
{t('settings.tools.mcp.saving')}
{t('settings.tools.mcp.testing')}
</span>
</div>
) : (
t('settings.tools.mcp.save')
t('settings.tools.mcp.testConnection')
)}
</button>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:gap-3">
<button
onClick={() => {
setModalState('INACTIVE');
resetForm();
}}
className="dark:text-light-gray w-full cursor-pointer rounded-3xl px-6 py-2 text-sm font-medium hover:bg-gray-100 sm:w-auto dark:bg-transparent dark:hover:bg-[#767183]/50"
>
{t('settings.tools.mcp.cancel')}
</button>
<button
onClick={handleSave}
disabled={loading}
className="bg-purple-30 hover:bg-violets-are-blue w-full rounded-3xl px-6 py-2 text-sm font-medium text-white transition-all disabled:opacity-50 sm:w-auto"
>
{loading ? (
<div className="flex items-center justify-center">
<Spinner size="small" />
<span className="ml-2">
{t('settings.tools.mcp.saving')}
</span>
</div>
) : (
t('settings.tools.mcp.save')
)}
</button>
</div>
</div>
</div>
</div>