mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-28 12:39:20 -05:00
Compare commits
5 Commits
v0.10.1
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc8ff4bedd | ||
|
|
29c1b332b6 | ||
|
|
8245195462 | ||
|
|
0de1eb6e69 | ||
|
|
44e0309fd4 |
22
Dockerfile
22
Dockerfile
@@ -9,9 +9,25 @@ RUN apt-get update && apt-get install -y curl gnupg git \
|
||||
|
||||
RUN npm install -g pnpm
|
||||
|
||||
ENV PNPM_HOME=/usr/local/share/pnpm
|
||||
ENV PATH=$PNPM_HOME:$PATH
|
||||
RUN mkdir -p $PNPM_HOME && \
|
||||
ENV MCP_DATA_DIR=/app/data
|
||||
ENV MCP_SERVERS_DIR=$MCP_DATA_DIR/servers
|
||||
ENV MCP_NPM_DIR=$MCP_SERVERS_DIR/npm
|
||||
ENV MCP_PYTHON_DIR=$MCP_SERVERS_DIR/python
|
||||
ENV PNPM_HOME=$MCP_DATA_DIR/pnpm
|
||||
ENV NPM_CONFIG_PREFIX=$MCP_DATA_DIR/npm-global
|
||||
ENV NPM_CONFIG_CACHE=$MCP_DATA_DIR/npm-cache
|
||||
ENV UV_TOOL_DIR=$MCP_DATA_DIR/uv/tools
|
||||
ENV UV_CACHE_DIR=$MCP_DATA_DIR/uv/cache
|
||||
ENV PATH=$PNPM_HOME:$NPM_CONFIG_PREFIX/bin:$UV_TOOL_DIR/bin:$PATH
|
||||
RUN mkdir -p \
|
||||
$PNPM_HOME \
|
||||
$NPM_CONFIG_PREFIX/bin \
|
||||
$NPM_CONFIG_PREFIX/lib/node_modules \
|
||||
$NPM_CONFIG_CACHE \
|
||||
$UV_TOOL_DIR \
|
||||
$UV_CACHE_DIR \
|
||||
$MCP_NPM_DIR \
|
||||
$MCP_PYTHON_DIR && \
|
||||
pnpm add -g @amap/amap-maps-mcp-server @playwright/mcp@latest tavily-mcp@latest @modelcontextprotocol/server-github @modelcontextprotocol/server-slack
|
||||
|
||||
ARG INSTALL_EXT=false
|
||||
|
||||
182
docs/transport-event-handlers-fix.md
Normal file
182
docs/transport-event-handlers-fix.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# Transport Event Handlers Fix
|
||||
|
||||
## Problem Statement
|
||||
|
||||
After adding SSE (Server-Sent Events) or Streamable HTTP protocol servers, the server status did not automatically update when connections failed or closed. The status remained "connected" even when the connection was lost.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The MCP SDK provides `onclose` and `onerror` event handlers for all transport types (SSE, StreamableHTTP, and stdio), but the MCPHub implementation was not setting up these handlers. This meant that:
|
||||
|
||||
1. When a connection closed unexpectedly, the server status remained "connected"
|
||||
2. When transport errors occurred, the status was not updated
|
||||
3. Users could not see the actual connection state in the dashboard
|
||||
|
||||
## Solution
|
||||
|
||||
Added a `setupTransportEventHandlers()` helper function that:
|
||||
|
||||
1. Sets up `onclose` handler to update status to 'disconnected' when connections close
|
||||
2. Sets up `onerror` handler to update status and capture error messages
|
||||
3. Clears keep-alive ping intervals when connections fail
|
||||
4. Logs connection state changes for debugging
|
||||
|
||||
The handlers are set up in two places:
|
||||
|
||||
1. After successful initial connection in `initializeClientsFromSettings()`
|
||||
2. After reconnection in `callToolWithReconnect()`
|
||||
|
||||
## Changes Made
|
||||
|
||||
### File: `src/services/mcpService.ts`
|
||||
|
||||
#### New Function: `setupTransportEventHandlers()`
|
||||
|
||||
```typescript
|
||||
const setupTransportEventHandlers = (serverInfo: ServerInfo): void => {
|
||||
if (!serverInfo.transport) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up onclose handler to update status when connection closes
|
||||
serverInfo.transport.onclose = () => {
|
||||
console.log(`Transport closed for server: ${serverInfo.name}`);
|
||||
if (serverInfo.status === 'connected') {
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = 'Connection closed';
|
||||
}
|
||||
|
||||
// Clear keep-alive interval if it exists
|
||||
if (serverInfo.keepAliveIntervalId) {
|
||||
clearInterval(serverInfo.keepAliveIntervalId);
|
||||
serverInfo.keepAliveIntervalId = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Set up onerror handler to update status on connection errors
|
||||
serverInfo.transport.onerror = (error: Error) => {
|
||||
console.error(`Transport error for server ${serverInfo.name}:`, error);
|
||||
if (serverInfo.status === 'connected') {
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Transport error: ${error.message}`;
|
||||
}
|
||||
|
||||
// Clear keep-alive interval if it exists
|
||||
if (serverInfo.keepAliveIntervalId) {
|
||||
clearInterval(serverInfo.keepAliveIntervalId);
|
||||
serverInfo.keepAliveIntervalId = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
console.log(`Transport event handlers set up for server: ${serverInfo.name}`);
|
||||
};
|
||||
```
|
||||
|
||||
#### Integration Points
|
||||
|
||||
1. **Initial Connection** - Added call after successful connection:
|
||||
```typescript
|
||||
if (!dataError) {
|
||||
serverInfo.status = 'connected';
|
||||
serverInfo.error = null;
|
||||
|
||||
// Set up transport event handlers for connection monitoring
|
||||
setupTransportEventHandlers(serverInfo);
|
||||
|
||||
// Set up keep-alive ping for SSE connections
|
||||
setupKeepAlive(serverInfo, expandedConf);
|
||||
}
|
||||
```
|
||||
|
||||
2. **Reconnection** - Added call after reconnection succeeds:
|
||||
```typescript
|
||||
// Update server info with new client and transport
|
||||
serverInfo.client = client;
|
||||
serverInfo.transport = newTransport;
|
||||
serverInfo.status = 'connected';
|
||||
|
||||
// Set up transport event handlers for the new connection
|
||||
setupTransportEventHandlers(serverInfo);
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Automated Tests
|
||||
|
||||
All 169 existing tests pass, including:
|
||||
- Integration tests for SSE transport (`tests/integration/sse-service-real-client.test.ts`)
|
||||
- Integration tests for StreamableHTTP transport
|
||||
- Unit tests for MCP service functionality
|
||||
|
||||
### Manual Testing
|
||||
|
||||
To manually test the fix:
|
||||
|
||||
1. **Add an SSE server** to `mcp_settings.json`:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"test-sse-server": {
|
||||
"type": "sse",
|
||||
"url": "http://localhost:9999/sse",
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Start MCPHub**: `pnpm dev`
|
||||
|
||||
3. **Observe the behavior**:
|
||||
- Server will initially show as "connecting"
|
||||
- When connection fails (port 9999 not available), status will update to "disconnected"
|
||||
- Error message will show: "Transport error: ..." or "Connection closed"
|
||||
|
||||
4. **Test connection recovery**:
|
||||
- Start an MCP server on the configured URL
|
||||
- The status should update to "connected" when available
|
||||
- Stop the MCP server
|
||||
- The status should update back to "disconnected"
|
||||
|
||||
### StreamableHTTP Testing
|
||||
|
||||
1. **Add a StreamableHTTP server** to `mcp_settings.json`:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"test-http-server": {
|
||||
"type": "streamable-http",
|
||||
"url": "http://localhost:9999/mcp",
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Follow the same testing steps as SSE
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Accurate Status**: Server status now reflects actual connection state
|
||||
2. **Better UX**: Users can see when connections fail in real-time
|
||||
3. **Debugging**: Error messages help diagnose connection issues
|
||||
4. **Resource Management**: Keep-alive intervals are properly cleaned up on connection failures
|
||||
5. **Consistent Behavior**: All transport types (SSE, StreamableHTTP, stdio) now have proper event handling
|
||||
|
||||
## Compatibility
|
||||
|
||||
- **Backwards Compatible**: No breaking changes to existing functionality
|
||||
- **SDK Version**: Requires `@modelcontextprotocol/sdk` v1.20.2 or higher (current version in use)
|
||||
- **Node.js**: Compatible with all supported Node.js versions
|
||||
- **Transport Types**: Works with SSEClientTransport, StreamableHTTPClientTransport, and StdioClientTransport
|
||||
|
||||
Note: The `onclose` and `onerror` event handlers are part of the Transport interface in the MCP SDK and have been available since early versions. The current implementation has been tested with SDK v1.20.2.
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements for the future:
|
||||
|
||||
1. Add automatic reconnection logic for transient failures
|
||||
2. Add connection health metrics (uptime, error count)
|
||||
3. Emit events for UI notifications when status changes
|
||||
4. Add configurable retry strategies per server
|
||||
@@ -1,5 +1,27 @@
|
||||
#!/bin/bash
|
||||
|
||||
DATA_DIR=${MCP_DATA_DIR:-/app/data}
|
||||
SERVERS_DIR=${MCP_SERVERS_DIR:-$DATA_DIR/servers}
|
||||
NPM_SERVER_DIR=${MCP_NPM_DIR:-$SERVERS_DIR/npm}
|
||||
PYTHON_SERVER_DIR=${MCP_PYTHON_DIR:-$SERVERS_DIR/python}
|
||||
PNPM_HOME=${PNPM_HOME:-$DATA_DIR/pnpm}
|
||||
NPM_CONFIG_PREFIX=${NPM_CONFIG_PREFIX:-$DATA_DIR/npm-global}
|
||||
NPM_CONFIG_CACHE=${NPM_CONFIG_CACHE:-$DATA_DIR/npm-cache}
|
||||
UV_TOOL_DIR=${UV_TOOL_DIR:-$DATA_DIR/uv/tools}
|
||||
UV_CACHE_DIR=${UV_CACHE_DIR:-$DATA_DIR/uv/cache}
|
||||
|
||||
mkdir -p \
|
||||
"$PNPM_HOME" \
|
||||
"$NPM_CONFIG_PREFIX/bin" \
|
||||
"$NPM_CONFIG_PREFIX/lib/node_modules" \
|
||||
"$NPM_CONFIG_CACHE" \
|
||||
"$UV_TOOL_DIR" \
|
||||
"$UV_CACHE_DIR" \
|
||||
"$NPM_SERVER_DIR" \
|
||||
"$PYTHON_SERVER_DIR"
|
||||
|
||||
export PATH="$PNPM_HOME:$NPM_CONFIG_PREFIX/bin:$UV_TOOL_DIR/bin:$PATH"
|
||||
|
||||
NPM_REGISTRY=${NPM_REGISTRY:-https://registry.npmjs.org/}
|
||||
echo "Setting npm registry to ${NPM_REGISTRY}"
|
||||
npm config set registry "$NPM_REGISTRY"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
@@ -31,6 +33,77 @@ const servers: { [sessionId: string]: Server } = {};
|
||||
|
||||
const serverDao = getServerDao();
|
||||
|
||||
const ensureDirExists = (dir: string | undefined): string => {
|
||||
if (!dir) {
|
||||
throw new Error('Directory path is undefined');
|
||||
}
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
};
|
||||
|
||||
const getDataRootDir = (): string => {
|
||||
return ensureDirExists(process.env.MCP_DATA_DIR || path.join(process.cwd(), 'data'));
|
||||
};
|
||||
|
||||
const getServersStorageRoot = (): string => {
|
||||
return ensureDirExists(process.env.MCP_SERVERS_DIR || path.join(getDataRootDir(), 'servers'));
|
||||
};
|
||||
|
||||
const getNpmBaseDir = (): string => {
|
||||
return ensureDirExists(process.env.MCP_NPM_DIR || path.join(getServersStorageRoot(), 'npm'));
|
||||
};
|
||||
|
||||
const getPythonBaseDir = (): string => {
|
||||
return ensureDirExists(
|
||||
process.env.MCP_PYTHON_DIR || path.join(getServersStorageRoot(), 'python'),
|
||||
);
|
||||
};
|
||||
|
||||
const getNpmCacheDir = (): string => {
|
||||
return ensureDirExists(process.env.NPM_CONFIG_CACHE || path.join(getDataRootDir(), 'npm-cache'));
|
||||
};
|
||||
|
||||
const getNpmPrefixDir = (): string => {
|
||||
const dir = ensureDirExists(
|
||||
process.env.NPM_CONFIG_PREFIX || path.join(getDataRootDir(), 'npm-global'),
|
||||
);
|
||||
ensureDirExists(path.join(dir, 'bin'));
|
||||
ensureDirExists(path.join(dir, 'lib', 'node_modules'));
|
||||
return dir;
|
||||
};
|
||||
|
||||
const getUvCacheDir = (): string => {
|
||||
return ensureDirExists(process.env.UV_CACHE_DIR || path.join(getDataRootDir(), 'uv', 'cache'));
|
||||
};
|
||||
|
||||
const getUvToolDir = (): string => {
|
||||
const dir = ensureDirExists(process.env.UV_TOOL_DIR || path.join(getDataRootDir(), 'uv', 'tools'));
|
||||
ensureDirExists(path.join(dir, 'bin'));
|
||||
return dir;
|
||||
};
|
||||
|
||||
const getServerInstallDir = (serverName: string, kind: 'npm' | 'python'): string => {
|
||||
const baseDir = kind === 'npm' ? getNpmBaseDir() : getPythonBaseDir();
|
||||
return ensureDirExists(path.join(baseDir, serverName));
|
||||
};
|
||||
|
||||
const prependToPath = (currentPath: string, dir: string): string => {
|
||||
if (!dir) {
|
||||
return currentPath;
|
||||
}
|
||||
const delimiter = path.delimiter;
|
||||
const segments = currentPath ? currentPath.split(delimiter) : [];
|
||||
if (segments.includes(dir)) {
|
||||
return currentPath;
|
||||
}
|
||||
return currentPath ? `${dir}${delimiter}${currentPath}` : dir;
|
||||
};
|
||||
|
||||
const NODE_COMMANDS = new Set(['npm', 'npx', 'pnpm', 'yarn', 'node', 'bun', 'bunx']);
|
||||
const PYTHON_COMMANDS = new Set(['uv', 'uvx', 'python', 'pip', 'pip3', 'pipx']);
|
||||
|
||||
// Helper function to set up keep-alive ping for SSE connections
|
||||
const setupKeepAlive = (serverInfo: ServerInfo, serverConfig: ServerConfig): void => {
|
||||
// Only set up keep-alive for SSE connections
|
||||
@@ -63,6 +136,48 @@ const setupKeepAlive = (serverInfo: ServerInfo, serverConfig: ServerConfig): voi
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to clean up server resources on disconnection
|
||||
const cleanupServerResources = (serverInfo: ServerInfo): void => {
|
||||
// Clear keep-alive interval if it exists
|
||||
if (serverInfo.keepAliveIntervalId) {
|
||||
clearInterval(serverInfo.keepAliveIntervalId);
|
||||
serverInfo.keepAliveIntervalId = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to set up transport event handlers for connection monitoring
|
||||
const setupTransportEventHandlers = (serverInfo: ServerInfo): void => {
|
||||
if (!serverInfo.transport) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up onclose handler to update status when connection closes
|
||||
serverInfo.transport.onclose = () => {
|
||||
console.log(`Transport closed for server: ${serverInfo.name}`);
|
||||
// Update status to disconnected if not already in a terminal state
|
||||
if (serverInfo.status === 'connected' || serverInfo.status === 'connecting') {
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = 'Connection closed';
|
||||
}
|
||||
|
||||
cleanupServerResources(serverInfo);
|
||||
};
|
||||
|
||||
// Set up onerror handler to update status on connection errors
|
||||
serverInfo.transport.onerror = (error: Error) => {
|
||||
console.error(`Transport error for server ${serverInfo.name}:`, error);
|
||||
// Update status to disconnected if not already in a terminal state
|
||||
if (serverInfo.status === 'connected' || serverInfo.status === 'connecting') {
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Transport error: ${error.message}`;
|
||||
}
|
||||
|
||||
cleanupServerResources(serverInfo);
|
||||
};
|
||||
|
||||
console.log(`Transport event handlers set up for server: ${serverInfo.name}`);
|
||||
};
|
||||
|
||||
export const initUpstreamServers = async (): Promise<void> => {
|
||||
// Initialize OAuth clients for servers with dynamic registration
|
||||
await initializeAllOAuthClients();
|
||||
@@ -213,7 +328,7 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
|
||||
...(process.env as Record<string, string>),
|
||||
...replaceEnvVars(conf.env || {}),
|
||||
};
|
||||
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
|
||||
env['PATH'] = expandEnvVars(env['PATH'] || process.env.PATH || '');
|
||||
|
||||
const settings = loadSettings();
|
||||
// Add UV_DEFAULT_INDEX and npm_config_registry if needed
|
||||
@@ -235,9 +350,52 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
|
||||
env['npm_config_registry'] = settings.systemConfig.install.npmRegistry;
|
||||
}
|
||||
|
||||
// Ensure stdio servers use persistent directories under /app/data (or configured override)
|
||||
let workingDirectory = os.homedir();
|
||||
const commandLower = conf.command.toLowerCase();
|
||||
|
||||
if (NODE_COMMANDS.has(commandLower)) {
|
||||
const serverDir = getServerInstallDir(name, 'npm');
|
||||
workingDirectory = serverDir;
|
||||
|
||||
const npmCacheDir = getNpmCacheDir();
|
||||
const npmPrefixDir = getNpmPrefixDir();
|
||||
|
||||
if (!env['npm_config_cache']) {
|
||||
env['npm_config_cache'] = npmCacheDir;
|
||||
}
|
||||
if (!env['NPM_CONFIG_CACHE']) {
|
||||
env['NPM_CONFIG_CACHE'] = env['npm_config_cache'];
|
||||
}
|
||||
|
||||
if (!env['npm_config_prefix']) {
|
||||
env['npm_config_prefix'] = npmPrefixDir;
|
||||
}
|
||||
if (!env['NPM_CONFIG_PREFIX']) {
|
||||
env['NPM_CONFIG_PREFIX'] = env['npm_config_prefix'];
|
||||
}
|
||||
|
||||
env['PATH'] = prependToPath(env['PATH'], path.join(env['npm_config_prefix'], 'bin'));
|
||||
} else if (PYTHON_COMMANDS.has(commandLower)) {
|
||||
const serverDir = getServerInstallDir(name, 'python');
|
||||
workingDirectory = serverDir;
|
||||
|
||||
const uvCacheDir = getUvCacheDir();
|
||||
const uvToolDir = getUvToolDir();
|
||||
|
||||
if (!env['UV_CACHE_DIR']) {
|
||||
env['UV_CACHE_DIR'] = uvCacheDir;
|
||||
}
|
||||
if (!env['UV_TOOL_DIR']) {
|
||||
env['UV_TOOL_DIR'] = uvToolDir;
|
||||
}
|
||||
|
||||
env['PATH'] = prependToPath(env['PATH'], path.join(env['UV_TOOL_DIR'], 'bin'));
|
||||
}
|
||||
|
||||
// Expand environment variables in command
|
||||
transport = new StdioClientTransport({
|
||||
cwd: os.homedir(),
|
||||
cwd: workingDirectory,
|
||||
command: conf.command,
|
||||
args: replaceEnvVars(conf.args) as string[],
|
||||
env: env,
|
||||
@@ -324,6 +482,9 @@ const callToolWithReconnect = async (
|
||||
serverInfo.transport = newTransport;
|
||||
serverInfo.status = 'connected';
|
||||
|
||||
// Set up transport event handlers for the new connection
|
||||
setupTransportEventHandlers(serverInfo);
|
||||
|
||||
// Reload tools list after reconnection
|
||||
try {
|
||||
const tools = await client.listTools({}, serverInfo.options || {});
|
||||
@@ -598,6 +759,9 @@ export const initializeClientsFromSettings = async (
|
||||
serverInfo.status = 'connected';
|
||||
serverInfo.error = null;
|
||||
|
||||
// Set up transport event handlers for connection monitoring
|
||||
setupTransportEventHandlers(serverInfo);
|
||||
|
||||
// Set up keep-alive ping for SSE connections
|
||||
setupKeepAlive(serverInfo, expandedConf);
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user