feat: add request options configuration to server form (#171)

This commit is contained in:
samanhappy
2025-06-10 13:51:01 +08:00
committed by GitHub
parent 77f64b7b98
commit 4726f00a22
8 changed files with 160 additions and 46 deletions

View File

@@ -41,7 +41,12 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
args: (initialData && initialData.config && initialData.config.args) || [],
type: getInitialServerType(), // Initialize the type field
env: [],
headers: []
headers: [],
options: {
timeout: (initialData && initialData.config && initialData.config.options && initialData.config.options.timeout) || 60000,
resetTimeoutOnProgress: (initialData && initialData.config && initialData.config.options && initialData.config.options.resetTimeoutOnProgress) || false,
maxTotalTimeout: (initialData && initialData.config && initialData.config.options && initialData.config.options.maxTotalTimeout) || undefined,
}
})
const [envVars, setEnvVars] = useState<EnvVar[]>(
@@ -56,6 +61,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
: [],
)
const [isRequestOptionsExpanded, setIsRequestOptionsExpanded] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const isEdit = !!initialData
@@ -66,7 +72,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
// Transform space-separated arguments string into array
const handleArgsChange = (value: string) => {
let args = value.split(' ').filter((arg) => arg.trim() !== '')
const args = value.split(' ').filter((arg) => arg.trim() !== '')
setFormData({ ...formData, arguments: value, args })
}
@@ -107,6 +113,17 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
setHeaderVars(newHeaderVars)
}
// Handle options changes
const handleOptionsChange = (field: 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout', value: number | boolean | undefined) => {
setFormData(prev => ({
...prev,
options: {
...prev.options,
[field]: value
}
}))
}
// Submit handler for server configuration
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -127,6 +144,18 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
}
})
// Prepare options object, only include defined values
const options: any = {}
if (formData.options?.timeout && formData.options.timeout !== 60000) {
options.timeout = formData.options.timeout
}
if (formData.options?.resetTimeoutOnProgress) {
options.resetTimeoutOnProgress = formData.options.resetTimeoutOnProgress
}
if (formData.options?.maxTotalTimeout) {
options.maxTotalTimeout = formData.options.maxTotalTimeout
}
const payload = {
name: formData.name,
config: {
@@ -141,7 +170,8 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
args: formData.args,
env: Object.keys(env).length > 0 ? env : undefined,
}
)
),
...(Object.keys(options).length > 0 ? { options } : {})
}
}
@@ -365,6 +395,75 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
</>
)}
{/* Request Options Configuration */}
<div className="mb-4">
<div
className="flex items-center justify-between cursor-pointer bg-gray-50 hover:bg-gray-100 p-3 rounded border"
onClick={() => setIsRequestOptionsExpanded(!isRequestOptionsExpanded)}
>
<label className="text-gray-700 text-sm font-bold">
{t('server.requestOptions')}
</label>
<span className="text-gray-500 text-sm">
{isRequestOptionsExpanded ? '▼' : '▶'}
</span>
</div>
{isRequestOptionsExpanded && (
<div className="border rounded-b p-4 bg-gray-50 border-t-0">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-gray-600 text-sm font-medium mb-1" htmlFor="timeout">
{t('server.timeout')}
</label>
<input
type="number"
id="timeout"
value={formData.options?.timeout || 60000}
onChange={(e) => handleOptionsChange('timeout', parseInt(e.target.value) || 60000)}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
placeholder="30000"
min="1000"
max="300000"
/>
<p className="text-xs text-gray-500 mt-1">{t('server.timeoutDescription')}</p>
</div>
<div>
<label className="block text-gray-600 text-sm font-medium mb-1" htmlFor="maxTotalTimeout">
{t('server.maxTotalTimeout')}
</label>
<input
type="number"
id="maxTotalTimeout"
value={formData.options?.maxTotalTimeout || ''}
onChange={(e) => handleOptionsChange('maxTotalTimeout', e.target.value ? parseInt(e.target.value) : undefined)}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
placeholder="Optional"
min="1000"
/>
<p className="text-xs text-gray-500 mt-1">{t('server.maxTotalTimeoutDescription')}</p>
</div>
</div>
<div className="mt-3">
<label className="flex items-center">
<input
type="checkbox"
checked={formData.options?.resetTimeoutOnProgress || false}
onChange={(e) => handleOptionsChange('resetTimeoutOnProgress', e.target.checked)}
className="mr-2"
/>
<span className="text-gray-600 text-sm">{t('server.resetTimeoutOnProgress')}</span>
</label>
<p className="text-xs text-gray-500 mt-1 ml-6">
{t('server.resetTimeoutOnProgressDescription')}
</p>
</div>
</div>
)}
</div>
<div className="flex justify-end mt-6">
<button
type="button"

View File

@@ -99,6 +99,13 @@
"enabled": "Enabled",
"enable": "Enable",
"disable": "Disable",
"requestOptions": "Configuration",
"timeout": "Request Timeout",
"timeoutDescription": "Timeout for requests to the MCP server (ms)",
"maxTotalTimeout": "Maximum Total Timeout",
"maxTotalTimeoutDescription": "Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)",
"resetTimeoutOnProgress": "Reset Timeout on Progress",
"resetTimeoutOnProgressDescription": "Reset timeout on progress notifications",
"remove": "Remove",
"toggleError": "Failed to toggle server {{serverName}}",
"alreadyExists": "Server {{serverName}} already exists",

View File

@@ -99,6 +99,13 @@
"enabled": "已启用",
"enable": "启用",
"disable": "禁用",
"requestOptions": "配置",
"timeout": "请求超时",
"timeoutDescription": "请求超时时间(毫秒)",
"maxTotalTimeout": "最大总超时",
"maxTotalTimeoutDescription": "无论是否有进度通知的最大总超时时间(毫秒)",
"resetTimeoutOnProgress": "收到进度通知时重置超时",
"resetTimeoutOnProgressDescription": "适用于发送周期性进度更新的长时间运行操作",
"remove": "移除",
"toggleError": "切换服务器 {{serverName}} 状态失败",
"alreadyExists": "服务器 {{serverName}} 已经存在",

View File

@@ -80,6 +80,11 @@ export interface ServerConfig {
headers?: Record<string, string>;
enabled?: boolean;
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
options?: {
timeout?: number; // Request timeout in milliseconds
resetTimeoutOnProgress?: boolean; // Reset timeout on progress notifications
maxTotalTimeout?: number; // Maximum total timeout in milliseconds
}; // MCP request options configuration
}
// Server types
@@ -116,6 +121,11 @@ export interface ServerFormData {
type?: 'stdio' | 'sse' | 'streamable-http'; // Added type field
env: EnvVar[];
headers: EnvVar[];
options?: {
timeout?: number;
resetTimeoutOnProgress?: boolean;
maxTotalTimeout?: number;
};
}
// Group form data types

View File

@@ -36,7 +36,7 @@ export const loadSettings = (): McpSettings => {
// Update cache
settingsCache = settings;
console.log(`Loaded settings from ${settingsPath}:`, settings);
console.log(`Loaded settings from ${settingsPath}`);
return settings;
} catch (error) {
console.error(`Failed to load settings from ${settingsPath}:`, error);

View File

@@ -1,37 +1,8 @@
import express, { Request, Response, NextFunction } from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import fs from 'fs';
import { auth } from './auth.js';
import { initializeDefaultUser } from '../models/User.js';
import config from '../config/index.js';
// Create __dirname equivalent for ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Try to find the correct frontend file path
const findFrontendPath = (): string => {
// First try development environment path
const devPath = path.join(dirname(__dirname), 'frontend', 'dist', 'index.html');
if (fs.existsSync(devPath)) {
return path.join(dirname(__dirname), 'frontend', 'dist');
}
// Try npm/npx installed path (remove /dist directory)
const npmPath = path.join(dirname(dirname(__dirname)), 'frontend', 'dist', 'index.html');
if (fs.existsSync(npmPath)) {
return path.join(dirname(dirname(__dirname)), 'frontend', 'dist');
}
// If none of the above paths exist, return the most reasonable default path and log a warning
console.warn('Warning: Could not locate frontend files. Using default path.');
return path.join(dirname(__dirname), 'frontend', 'dist');
};
const frontendPath = findFrontendPath();
export const errorHandler = (
err: Error,
_req: Request,
@@ -52,6 +23,7 @@ export const initMiddlewares = (app: express.Application): void => {
app.use((req, res, next) => {
const basePath = config.basePath;
// Only apply JSON parsing for API and auth routes, not for SSE or message endpoints
// TODO exclude sse responses by mcp endpoint
if (
req.path !== `${basePath}/sse` &&
!req.path.startsWith(`${basePath}/sse/`) &&

View File

@@ -218,14 +218,27 @@ export const initializeClientsFromSettings = (isInit: boolean): ServerInfo[] =>
},
},
);
const timeout = isInit ? Number(config.initTimeout) : Number(config.timeout);
const initRequestOptions = isInit
? {
timeout: Number(config.initTimeout) || 60000,
}
: undefined;
// Get request options from server configuration, with fallbacks
const serverRequestOptions = conf.options || {};
const requestOptions = {
timeout: serverRequestOptions.timeout || 60000,
resetTimeoutOnProgress: serverRequestOptions.resetTimeoutOnProgress || false,
maxTotalTimeout: serverRequestOptions.maxTotalTimeout,
};
client
.connect(transport, { timeout: timeout })
.connect(transport, initRequestOptions || requestOptions)
.then(() => {
console.log(`Successfully connected client for server: ${name}`);
client
.listTools({}, { timeout: timeout })
.listTools({}, initRequestOptions || requestOptions)
.then((tools) => {
console.log(`Successfully listed ${tools.tools.length} tools for server: ${name}`);
const serverInfo = getServerByName(name);
@@ -276,6 +289,7 @@ export const initializeClientsFromSettings = (isInit: boolean): ServerInfo[] =>
tools: [],
client,
transport,
options: requestOptions,
createTime: Date.now(),
});
console.log(`Initialized client for server: ${name}`);
@@ -696,14 +710,12 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
// Special handling for call_tool
if (request.params.name === 'call_tool') {
let { toolName, arguments: toolArgs = {} } = request.params.arguments || {};
let { toolName } = request.params.arguments || {};
if (!toolName) {
throw new Error('toolName parameter is required');
}
// arguments parameter is now optional
const { arguments: toolArgs = {} } = request.params.arguments || {};
let targetServerInfo: ServerInfo | undefined;
if (extra && extra.server) {
targetServerInfo = getServerByName(extra.server);
@@ -744,10 +756,14 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
toolName = toolName.startsWith(`${targetServerInfo.name}-`)
? toolName.replace(`${targetServerInfo.name}-`, '')
: toolName;
const result = await client.callTool({
name: toolName,
arguments: finalArgs,
});
const result = await client.callTool(
{
name: toolName,
arguments: finalArgs,
},
undefined,
targetServerInfo.options || {},
);
console.log(`Tool invocation result: ${JSON.stringify(result)}`);
return result;
@@ -766,7 +782,7 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
request.params.name = request.params.name.startsWith(`${serverInfo.name}-`)
? request.params.name.replace(`${serverInfo.name}-`, '')
: request.params.name;
const result = await client.callTool(request.params);
const result = await client.callTool(request.params, undefined, serverInfo.options || {});
console.log(`Tool call result: ${JSON.stringify(result)}`);
return result;
} catch (error) {

View File

@@ -2,6 +2,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
import { SmartRoutingConfig } from '../utils/smartRouting.js';
// User interface
@@ -107,6 +108,7 @@ export interface ServerConfig {
enabled?: boolean; // Flag to enable/disable the server
keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms for SSE servers)
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
options?: Partial<Pick<RequestOptions, 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout'>>; // MCP request options configuration
}
// Information about a server's status and tools
@@ -117,6 +119,7 @@ export interface ServerInfo {
tools: ToolInfo[]; // List of tools available on the server
client?: Client; // Client instance for communication
transport?: SSEClientTransport | StdioClientTransport | StreamableHTTPClientTransport; // Transport mechanism used
options?: RequestOptions; // Options for requests
createTime: number; // Timestamp of when the server was created
enabled?: boolean; // Flag to indicate if the server is enabled
keepAliveIntervalId?: NodeJS.Timeout; // Timer ID for keep-alive ping interval