feat: Enhance Keep-Alive configuration handling (#455)

This commit is contained in:
samanhappy
2025-11-30 09:59:48 +08:00
committed by GitHub
parent 063b081297
commit 8770b9ccfe
14 changed files with 234 additions and 175 deletions

View File

@@ -95,6 +95,11 @@ const ServerForm = ({
undefined, undefined,
}, },
oauth: getInitialOAuthConfig(initialData), oauth: getInitialOAuthConfig(initialData),
// KeepAlive configuration initialization
keepAlive: {
enabled: initialData?.config?.enableKeepAlive || false,
interval: initialData?.config?.keepAliveInterval || 60000,
},
// OpenAPI configuration initialization // OpenAPI configuration initialization
openapi: openapi:
initialData && initialData.config && initialData.config.openapi initialData && initialData.config && initialData.config.openapi
@@ -151,6 +156,7 @@ const ServerForm = ({
const [isRequestOptionsExpanded, setIsRequestOptionsExpanded] = useState<boolean>(false); const [isRequestOptionsExpanded, setIsRequestOptionsExpanded] = useState<boolean>(false);
const [isOAuthSectionExpanded, setIsOAuthSectionExpanded] = useState<boolean>(false); const [isOAuthSectionExpanded, setIsOAuthSectionExpanded] = useState<boolean>(false);
const [isKeepAliveSectionExpanded, setIsKeepAliveSectionExpanded] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const isEdit = !!initialData; const isEdit = !!initialData;
@@ -377,6 +383,15 @@ const ServerForm = ({
env: Object.keys(env).length > 0 ? env : undefined, env: Object.keys(env).length > 0 ? env : undefined,
}), }),
...(Object.keys(options).length > 0 ? { options } : {}), ...(Object.keys(options).length > 0 ? { options } : {}),
// KeepAlive configuration (only for SSE/streamable-http types)
...(serverType === 'sse' || serverType === 'streamable-http'
? {
enableKeepAlive: formData.keepAlive?.enabled || false,
...(formData.keepAlive?.enabled
? { keepAliveInterval: formData.keepAlive.interval || 60000 }
: {}),
}
: {}),
}, },
}; };
@@ -1255,6 +1270,86 @@ const ServerForm = ({
</div> </div>
)} )}
{/* KeepAlive Configuration - only for SSE/Streamable HTTP */}
{(serverType === 'sse' || serverType === 'streamable-http') && (
<div className="mb-4">
<div
className="flex items-center justify-between cursor-pointer bg-gray-50 hover:bg-gray-100 p-3 rounded border border-gray-200"
onClick={() => setIsKeepAliveSectionExpanded(!isKeepAliveSectionExpanded)}
>
<label className="text-gray-700 text-sm font-bold">
{t('server.keepAlive', 'Keep-Alive')}
</label>
<span className="text-gray-500 text-sm">
{isKeepAliveSectionExpanded ? '▼' : '▶'}
</span>
</div>
{isKeepAliveSectionExpanded && (
<div className="border border-gray-200 rounded-b p-4 bg-gray-50 border-t-0">
<div className="flex items-center mb-3">
<input
type="checkbox"
id="enableKeepAlive"
checked={formData.keepAlive?.enabled || false}
onChange={(e) =>
setFormData((prev) => ({
...prev,
keepAlive: {
...prev.keepAlive,
enabled: e.target.checked,
},
}))
}
className="mr-2"
/>
<label htmlFor="enableKeepAlive" className="text-gray-600 text-sm">
{t('server.enableKeepAlive', 'Enable Keep-Alive')}
</label>
</div>
<p className="text-xs text-gray-500 mb-3">
{t(
'server.keepAliveDescription',
'Send periodic ping requests to maintain the connection. Useful for long-running connections that may timeout.',
)}
</p>
<div>
<label
className="block text-gray-600 text-sm font-medium mb-1"
htmlFor="keepAliveInterval"
>
{t('server.keepAliveInterval', 'Interval (ms)')}
</label>
<input
type="number"
id="keepAliveInterval"
value={formData.keepAlive?.interval || 60000}
onChange={(e) =>
setFormData((prev) => ({
...prev,
keepAlive: {
...prev.keepAlive,
interval: 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 form-input"
placeholder="60000"
min="5000"
max="300000"
/>
<p className="text-xs text-gray-500 mt-1">
{t(
'server.keepAliveIntervalDescription',
'Time between keep-alive pings in milliseconds (default: 60000ms = 1 minute)',
)}
</p>
</div>
</div>
)}
</div>
)}
<div className="flex justify-end mt-6"> <div className="flex justify-end mt-6">
<button <button
type="button" type="button"

View File

@@ -114,6 +114,8 @@ export interface ServerConfig {
env?: Record<string, string>; env?: Record<string, string>;
headers?: Record<string, string>; headers?: Record<string, string>;
enabled?: boolean; enabled?: boolean;
enableKeepAlive?: boolean; // Enable keep-alive for this server (requires global enable as well)
keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms)
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
prompts?: Record<string, { enabled: boolean; description?: string }>; // Prompt-specific configurations with enable/disable state and custom descriptions prompts?: Record<string, { enabled: boolean; description?: string }>; // Prompt-specific configurations with enable/disable state and custom descriptions
options?: { options?: {
@@ -250,6 +252,10 @@ export interface ServerFormData {
resetTimeoutOnProgress?: boolean; resetTimeoutOnProgress?: boolean;
maxTotalTimeout?: number; maxTotalTimeout?: number;
}; };
keepAlive?: {
enabled?: boolean;
interval?: number;
};
oauth?: { oauth?: {
clientId?: string; clientId?: string;
clientSecret?: string; clientSecret?: string;

View File

@@ -123,6 +123,11 @@
"maxTotalTimeoutDescription": "Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)", "maxTotalTimeoutDescription": "Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)",
"resetTimeoutOnProgress": "Reset Timeout on Progress", "resetTimeoutOnProgress": "Reset Timeout on Progress",
"resetTimeoutOnProgressDescription": "Reset timeout on progress notifications", "resetTimeoutOnProgressDescription": "Reset timeout on progress notifications",
"keepAlive": "Keep-Alive Configuration",
"enableKeepAlive": "Enable Keep-Alive",
"keepAliveDescription": "Send periodic ping requests to maintain the connection. Useful for long-running connections that may timeout.",
"keepAliveInterval": "Interval (ms)",
"keepAliveIntervalDescription": "Time between keep-alive pings in milliseconds (default: 60000ms = 1 minute)",
"remove": "Remove", "remove": "Remove",
"toggleError": "Failed to toggle server {{serverName}}", "toggleError": "Failed to toggle server {{serverName}}",
"alreadyExists": "Server {{serverName}} already exists", "alreadyExists": "Server {{serverName}} already exists",

View File

@@ -123,6 +123,11 @@
"maxTotalTimeoutDescription": "Délai d'attente total maximum pour les requêtes envoyées au serveur MCP (ms) (à utiliser avec les notifications de progression)", "maxTotalTimeoutDescription": "Délai d'attente total maximum pour les requêtes envoyées au serveur MCP (ms) (à utiliser avec les notifications de progression)",
"resetTimeoutOnProgress": "Réinitialiser le délai d'attente en cas de progression", "resetTimeoutOnProgress": "Réinitialiser le délai d'attente en cas de progression",
"resetTimeoutOnProgressDescription": "Réinitialiser le délai d'attente lors des notifications de progression", "resetTimeoutOnProgressDescription": "Réinitialiser le délai d'attente lors des notifications de progression",
"keepAlive": "Configuration du maintien de connexion",
"enableKeepAlive": "Activer le maintien de connexion",
"keepAliveDescription": "Envoyer des requêtes ping périodiques pour maintenir la connexion. Utile pour les connexions de longue durée qui peuvent expirer.",
"keepAliveInterval": "Intervalle (ms)",
"keepAliveIntervalDescription": "Temps entre les pings de maintien de connexion en millisecondes (par défaut : 60000ms = 1 minute)",
"remove": "Retirer", "remove": "Retirer",
"toggleError": "Échec du basculement du serveur {{serverName}}", "toggleError": "Échec du basculement du serveur {{serverName}}",
"alreadyExists": "Le serveur {{serverName}} existe déjà", "alreadyExists": "Le serveur {{serverName}} existe déjà",

View File

@@ -123,6 +123,11 @@
"maxTotalTimeoutDescription": "MCP sunucusuna gönderilen istekler için maksimum toplam zaman aşımı (ms) (İlerleme bildirimleriyle kullanın)", "maxTotalTimeoutDescription": "MCP sunucusuna gönderilen istekler için maksimum toplam zaman aşımı (ms) (İlerleme bildirimleriyle kullanın)",
"resetTimeoutOnProgress": "İlerlemede Zaman Aşımını Sıfırla", "resetTimeoutOnProgress": "İlerlemede Zaman Aşımını Sıfırla",
"resetTimeoutOnProgressDescription": "İlerleme bildirimlerinde zaman aşımını sıfırla", "resetTimeoutOnProgressDescription": "İlerleme bildirimlerinde zaman aşımını sıfırla",
"keepAlive": "Bağlantı Canlı Tutma Yapılandırması",
"enableKeepAlive": "Bağlantı Canlı Tutmayı Etkinleştir",
"keepAliveDescription": "Bağlantıyı korumak için periyodik ping istekleri gönderin. Zaman aşımına uğrayabilecek uzun süreli bağlantılar için yararlıdır.",
"keepAliveInterval": "Aralık (ms)",
"keepAliveIntervalDescription": "Canlı tutma pingleri arasındaki süre milisaniye cinsinden (varsayılan: 60000ms = 1 dakika)",
"remove": "Kaldır", "remove": "Kaldır",
"toggleError": "{{serverName}} sunucusu açılamadı/kapatılamadı", "toggleError": "{{serverName}} sunucusu açılamadı/kapatılamadı",
"alreadyExists": "{{serverName}} sunucusu zaten mevcut", "alreadyExists": "{{serverName}} sunucusu zaten mevcut",

View File

@@ -123,6 +123,11 @@
"maxTotalTimeoutDescription": "无论是否有进度通知的最大总超时时间(毫秒)", "maxTotalTimeoutDescription": "无论是否有进度通知的最大总超时时间(毫秒)",
"resetTimeoutOnProgress": "收到进度通知时重置超时", "resetTimeoutOnProgress": "收到进度通知时重置超时",
"resetTimeoutOnProgressDescription": "适用于发送周期性进度更新的长时间运行操作", "resetTimeoutOnProgressDescription": "适用于发送周期性进度更新的长时间运行操作",
"keepAlive": "保活配置",
"enableKeepAlive": "启用保活",
"keepAliveDescription": "定期发送 ping 请求以维持连接。适用于可能超时的长期连接。",
"keepAliveInterval": "间隔时间(毫秒)",
"keepAliveIntervalDescription": "保活 ping 的时间间隔默认60000毫秒 = 1分钟",
"remove": "移除", "remove": "移除",
"toggleError": "切换服务器 {{serverName}} 状态失败", "toggleError": "切换服务器 {{serverName}} 状态失败",
"alreadyExists": "服务器 {{serverName}} 已经存在", "alreadyExists": "服务器 {{serverName}} 已经存在",

View File

@@ -32,6 +32,7 @@ export class ServerDaoDbImpl implements ServerDao {
headers: entity.headers, headers: entity.headers,
enabled: entity.enabled !== undefined ? entity.enabled : true, enabled: entity.enabled !== undefined ? entity.enabled : true,
owner: entity.owner, owner: entity.owner,
enableKeepAlive: entity.enableKeepAlive,
keepAliveInterval: entity.keepAliveInterval, keepAliveInterval: entity.keepAliveInterval,
tools: entity.tools, tools: entity.tools,
prompts: entity.prompts, prompts: entity.prompts,
@@ -41,7 +42,10 @@ export class ServerDaoDbImpl implements ServerDao {
return this.mapToServerConfig(server); return this.mapToServerConfig(server);
} }
async update(name: string, entity: Partial<ServerConfigWithName>): Promise<ServerConfigWithName | null> { async update(
name: string,
entity: Partial<ServerConfigWithName>,
): Promise<ServerConfigWithName | null> {
const server = await this.repository.update(name, { const server = await this.repository.update(name, {
type: entity.type, type: entity.type,
url: entity.url, url: entity.url,
@@ -51,6 +55,7 @@ export class ServerDaoDbImpl implements ServerDao {
headers: entity.headers, headers: entity.headers,
enabled: entity.enabled, enabled: entity.enabled,
owner: entity.owner, owner: entity.owner,
enableKeepAlive: entity.enableKeepAlive,
keepAliveInterval: entity.keepAliveInterval, keepAliveInterval: entity.keepAliveInterval,
tools: entity.tools, tools: entity.tools,
prompts: entity.prompts, prompts: entity.prompts,
@@ -118,6 +123,7 @@ export class ServerDaoDbImpl implements ServerDao {
headers?: Record<string, string>; headers?: Record<string, string>;
enabled: boolean; enabled: boolean;
owner?: string; owner?: string;
enableKeepAlive?: boolean;
keepAliveInterval?: number; keepAliveInterval?: number;
tools?: Record<string, { enabled: boolean; description?: string }>; tools?: Record<string, { enabled: boolean; description?: string }>;
prompts?: Record<string, { enabled: boolean; description?: string }>; prompts?: Record<string, { enabled: boolean; description?: string }>;
@@ -134,6 +140,7 @@ export class ServerDaoDbImpl implements ServerDao {
headers: server.headers, headers: server.headers,
enabled: server.enabled, enabled: server.enabled,
owner: server.owner, owner: server.owner,
enableKeepAlive: server.enableKeepAlive,
keepAliveInterval: server.keepAliveInterval, keepAliveInterval: server.keepAliveInterval,
tools: server.tools, tools: server.tools,
prompts: server.prompts, prompts: server.prompts,

View File

@@ -41,6 +41,9 @@ export class Server {
@Column({ type: 'varchar', length: 255, nullable: true }) @Column({ type: 'varchar', length: 255, nullable: true })
owner?: string; owner?: string;
@Column({ type: 'boolean', default: false })
enableKeepAlive?: boolean;
@Column({ type: 'int', nullable: true }) @Column({ type: 'int', nullable: true })
keepAliveInterval?: number; keepAliveInterval?: number;

View File

@@ -0,0 +1,73 @@
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { ServerInfo, ServerConfig } from '../types/index.js';
export interface KeepAliveOptions {
enabled?: boolean;
intervalMs?: number;
}
/**
* Set up keep-alive ping for MCP client connections (SSE or Streamable HTTP).
* Keepalive is controlled per-server via `serverConfig.enableKeepAlive` (default off).
*/
export const setupClientKeepAlive = async (
serverInfo: ServerInfo,
serverConfig: ServerConfig,
): Promise<void> => {
// Only set up keep-alive for SSE or Streamable HTTP client transports
const isSSE = serverInfo.transport instanceof SSEClientTransport;
const isStreamableHttp = serverInfo.transport instanceof StreamableHTTPClientTransport;
if (!isSSE && !isStreamableHttp) {
return;
}
const enabled = serverConfig.enableKeepAlive === true;
if (!enabled) {
// Ensure any previous timer is cleared
if (serverInfo.keepAliveIntervalId) {
clearInterval(serverInfo.keepAliveIntervalId as NodeJS.Timeout);
serverInfo.keepAliveIntervalId = undefined;
}
return;
}
// Clear any existing interval first
if (serverInfo.keepAliveIntervalId) {
clearInterval(serverInfo.keepAliveIntervalId as NodeJS.Timeout);
}
// Default interval: 60 seconds
const interval = serverConfig.keepAliveInterval || 60000;
serverInfo.keepAliveIntervalId = setInterval(async () => {
try {
if (serverInfo.client && serverInfo.status === 'connected') {
// Use client.ping() if available, otherwise fallback to listTools
if (typeof (serverInfo.client as any).ping === 'function') {
await (serverInfo.client as any).ping();
console.log(`Keep-alive ping successful for server: ${serverInfo.name}`);
} else {
await serverInfo.client.listTools({ timeout: 5000 }).catch(() => void 0);
}
}
} catch (error) {
console.warn(`Keep-alive ping failed for server ${serverInfo.name}:`, error);
}
}, interval);
console.log(
`Keep-alive enabled for server ${serverInfo.name} at ${Math.round(interval / 1000)}s interval`,
);
};
/**
* Clear keep-alive timer for a server.
*/
export const clearClientKeepAlive = (serverInfo: ServerInfo): void => {
if (serverInfo.keepAliveIntervalId) {
clearInterval(serverInfo.keepAliveIntervalId as NodeJS.Timeout);
serverInfo.keepAliveIntervalId = undefined;
console.log(`Cleared keep-alive interval for server: ${serverInfo.name}`);
}
};

View File

@@ -29,37 +29,7 @@ import { createOAuthProvider } from './mcpOAuthProvider.js';
const servers: { [sessionId: string]: Server } = {}; const servers: { [sessionId: string]: Server } = {};
// Helper function to set up keep-alive ping for SSE connections import { setupClientKeepAlive } from './keepAliveService.js';
const setupKeepAlive = (serverInfo: ServerInfo, serverConfig: ServerConfig): void => {
// Only set up keep-alive for SSE connections
if (!(serverInfo.transport instanceof SSEClientTransport)) {
return;
}
// Clear any existing interval first
if (serverInfo.keepAliveIntervalId) {
clearInterval(serverInfo.keepAliveIntervalId);
}
// Use configured interval or default to 60 seconds for SSE
const interval = serverConfig.keepAliveInterval || 60000;
serverInfo.keepAliveIntervalId = setInterval(async () => {
try {
if (serverInfo.client && serverInfo.status === 'connected') {
await serverInfo.client.ping();
console.log(`Keep-alive ping successful for server: ${serverInfo.name}`);
}
} catch (error) {
console.warn(`Keep-alive ping failed for server ${serverInfo.name}:`, error);
// TODO Consider handling reconnection logic here if needed
}
}, interval);
console.log(
`Keep-alive ping set up for server ${serverInfo.name} with interval ${interval / 1000} seconds`,
);
};
export const initUpstreamServers = async (): Promise<void> => { export const initUpstreamServers = async (): Promise<void> => {
// Initialize OAuth clients for servers with dynamic registration // Initialize OAuth clients for servers with dynamic registration
@@ -596,9 +566,10 @@ export const initializeClientsFromSettings = async (
if (!dataError) { if (!dataError) {
serverInfo.status = 'connected'; serverInfo.status = 'connected';
serverInfo.error = null; serverInfo.error = null;
// Set up keep-alive ping for SSE connections via shared service
// Set up keep-alive ping for SSE connections setupClientKeepAlive(serverInfo, expandedConf).catch((e) =>
setupKeepAlive(serverInfo, expandedConf); console.warn(`Keepalive setup failed for ${name}:`, e),
);
} else { } else {
serverInfo.status = 'disconnected'; serverInfo.status = 'disconnected';
serverInfo.error = `Failed to list data: ${dataError} `; serverInfo.error = `Failed to list data: ${dataError} `;
@@ -812,10 +783,9 @@ export const addOrUpdateServer = async (
return { success: false, message: 'Server name already exists' }; return { success: false, message: 'Server name already exists' };
} }
// If overriding and this is a DXT server (stdio type with file paths), // If overriding an existing server, close connections and clear keep-alive timers
// we might want to clean up old files in the future if (exists) {
if (exists && config.type === 'stdio') { // Close existing server connections (clears keep-alive intervals as well)
// Close existing server connections
closeServer(name); closeServer(name);
// Remove from server infos // Remove from server infos
serverInfos = serverInfos.filter((serverInfo) => serverInfo.name !== name); serverInfos = serverInfos.filter((serverInfo) => serverInfo.name !== name);

View File

@@ -215,25 +215,7 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
const transport = new SSEServerTransport(messagesPath, res); const transport = new SSEServerTransport(messagesPath, res);
transports[transport.sessionId] = { transport, group: group }; transports[transport.sessionId] = { transport, group: group };
// Send keepalive ping every 30 seconds to prevent client from closing connection
const keepAlive = setInterval(() => {
try {
// Send a ping notification to keep the connection alive
transport.send({ jsonrpc: '2.0', method: 'ping' });
console.log(`Sent keepalive ping for SSE session: ${transport.sessionId}`);
} catch (e) {
// If sending a ping fails, the connection is likely broken.
// Log the error and clear the interval to prevent further attempts.
console.warn(
`Failed to send keepalive ping for SSE session ${transport.sessionId}, cleaning up interval:`,
e,
);
clearInterval(keepAlive);
}
}, 30000); // Send ping every 30 seconds
res.on('close', () => { res.on('close', () => {
clearInterval(keepAlive);
delete transports[transport.sessionId]; delete transports[transport.sessionId];
deleteMcpServer(transport.sessionId); deleteMcpServer(transport.sessionId);
console.log(`SSE connection closed: ${transport.sessionId}`); console.log(`SSE connection closed: ${transport.sessionId}`);
@@ -329,26 +311,8 @@ async function createSessionWithId(
}, },
}); });
// Send keepalive ping every 30 seconds to prevent client from closing connection
const keepAlive = setInterval(() => {
try {
// Send a ping notification to keep the connection alive
transport.send({ jsonrpc: '2.0', method: 'ping' });
console.log(`Sent keepalive ping for StreamableHTTP session: ${sessionId}`);
} catch (e) {
// If sending a ping fails, the connection is likely broken.
// Log the error and clear the interval to prevent further attempts.
console.warn(
`Failed to send keepalive ping for StreamableHTTP session ${sessionId}, cleaning up interval:`,
e,
);
clearInterval(keepAlive);
}
}, 30000); // Send ping every 30 seconds
transport.onclose = () => { transport.onclose = () => {
console.log(`[SESSION REBUILD] Transport closed: ${sessionId}`); console.log(`[SESSION REBUILD] Transport closed: ${sessionId}`);
clearInterval(keepAlive);
delete transports[sessionId]; delete transports[sessionId];
deleteMcpServer(sessionId); deleteMcpServer(sessionId);
}; };
@@ -397,26 +361,8 @@ async function createNewSession(
}, },
}); });
// Send keepalive ping every 30 seconds to prevent client from closing connection
const keepAlive = setInterval(() => {
try {
// Send a ping notification to keep the connection alive
transport.send({ jsonrpc: '2.0', method: 'ping' });
console.log(`Sent keepalive ping for StreamableHTTP session: ${newSessionId}`);
} catch (e) {
// If sending a ping fails, the connection is likely broken.
// Log the error and clear the interval to prevent further attempts.
console.warn(
`Failed to send keepalive ping for StreamableHTTP session ${newSessionId}, cleaning up interval:`,
e,
);
clearInterval(keepAlive);
}
}, 30000); // Send ping every 30 seconds
transport.onclose = () => { transport.onclose = () => {
console.log(`[SESSION NEW] Transport closed: ${newSessionId}`); console.log(`[SESSION NEW] Transport closed: ${newSessionId}`);
clearInterval(keepAlive);
delete transports[newSessionId]; delete transports[newSessionId];
deleteMcpServer(newSessionId); deleteMcpServer(newSessionId);
}; };

View File

@@ -266,6 +266,7 @@ export interface ServerConfig {
headers?: Record<string, string>; // HTTP headers for SSE/streamable-http/openapi servers headers?: Record<string, string>; // HTTP headers for SSE/streamable-http/openapi servers
enabled?: boolean; // Flag to enable/disable the server enabled?: boolean; // Flag to enable/disable the server
owner?: string; // Owner of the server, defaults to 'admin' user owner?: string; // Owner of the server, defaults to 'admin' user
enableKeepAlive?: boolean; // Enable keep-alive for this server (requires global enable as well)
keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms for SSE servers) 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 tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
prompts?: Record<string, { enabled: boolean; description?: string }>; // Prompt-specific configurations with enable/disable state and custom descriptions prompts?: Record<string, { enabled: boolean; description?: string }>; // Prompt-specific configurations with enable/disable state and custom descriptions

View File

@@ -65,6 +65,7 @@ export async function migrateToDatabase(): Promise<boolean> {
headers: config.headers, headers: config.headers,
enabled: config.enabled !== undefined ? config.enabled : true, enabled: config.enabled !== undefined ? config.enabled : true,
owner: config.owner, owner: config.owner,
enableKeepAlive: config.enableKeepAlive,
keepAliveInterval: config.keepAliveInterval, keepAliveInterval: config.keepAliveInterval,
tools: config.tools, tools: config.tools,
prompts: config.prompts, prompts: config.prompts,

View File

@@ -171,100 +171,37 @@ describe('Keepalive Functionality', () => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
describe('SSE Connection Keepalive', () => { describe('SSE Connection (No Server-Side Keepalive)', () => {
it('should create a keepalive interval when establishing SSE connection', async () => { // Server-side keepalive was removed - keepalive is now only for upstream MCP server connections (client-side)
// These tests verify that SSE connections work without server-side keepalive
it('should establish SSE connection without keepalive interval', async () => {
await handleSseConnection(mockReq as Request, mockRes as Response); await handleSseConnection(mockReq as Request, mockRes as Response);
// Verify setInterval was called with 30000ms (30 seconds) // Verify no keepalive interval was created for server-side SSE
expect(global.setInterval).toHaveBeenCalledWith(expect.any(Function), 30000); expect(global.setInterval).not.toHaveBeenCalled();
}); });
it('should send ping messages via transport', async () => { it('should register close event handler for cleanup', async () => {
jest.useFakeTimers();
await handleSseConnection(mockReq as Request, mockRes as Response); await handleSseConnection(mockReq as Request, mockRes as Response);
// Fast-forward time by 30 seconds // Verify close event handler was registered
jest.advanceTimersByTime(30000); expect(mockRes.on).toHaveBeenCalledWith('close', expect.any(Function));
// Verify ping was sent using mockTransportInstance
expect(mockTransportInstance.send).toHaveBeenCalledWith({
jsonrpc: '2.0',
method: 'ping',
});
jest.useRealTimers();
}); });
it('should send multiple pings at 30-second intervals', async () => { it('should clean up transport on connection close', async () => {
jest.useFakeTimers();
await handleSseConnection(mockReq as Request, mockRes as Response); await handleSseConnection(mockReq as Request, mockRes as Response);
// Fast-forward time by 90 seconds (3 intervals) // Verify transport was registered
jest.advanceTimersByTime(90000); expect(transports['test-session-id']).toBeDefined();
// Verify ping was sent 3 times using mockTransportInstance
expect(mockTransportInstance.send).toHaveBeenCalledTimes(3);
expect(mockTransportInstance.send).toHaveBeenCalledWith({
jsonrpc: '2.0',
method: 'ping',
});
jest.useRealTimers();
});
it('should clear keepalive interval when connection closes', async () => {
await handleSseConnection(mockReq as Request, mockRes as Response);
// Verify interval was created
expect(global.setInterval).toHaveBeenCalled();
const intervalsBefore = intervals.length;
expect(intervalsBefore).toBeGreaterThan(0);
// Simulate connection close // Simulate connection close
if (eventListeners['close']) { if (eventListeners['close']) {
eventListeners['close'](); eventListeners['close']();
} }
// Verify clearInterval was called // Verify transport was removed
expect(global.clearInterval).toHaveBeenCalled(); expect(transports['test-session-id']).toBeUndefined();
expect(intervals.length).toBeLessThan(intervalsBefore);
});
it('should handle ping send errors gracefully', async () => {
jest.useFakeTimers();
await handleSseConnection(mockReq as Request, mockRes as Response);
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
// Make transport.send throw an error on the first call
let callCount = 0;
mockTransportInstance.send.mockImplementation(() => {
callCount++;
throw new Error('Connection broken');
});
// Fast-forward time by 30 seconds (first ping)
jest.advanceTimersByTime(30000);
// Verify error was logged for the first ping
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to send keepalive ping'),
expect.any(Error),
);
const firstCallCount = callCount;
// Fast-forward time by another 30 seconds
jest.advanceTimersByTime(30000);
// Verify no additional attempts were made after the error (interval was cleared)
expect(callCount).toBe(firstCallCount);
consoleWarnSpy.mockRestore();
jest.useRealTimers();
}); });
it('should not send pings after connection is closed', async () => { it('should not send pings after connection is closed', async () => {