mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
Enhance MCP settings export with error handling and null value removal (#465)
This commit is contained in:
@@ -106,6 +106,10 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
try {
|
try {
|
||||||
const result = await exportMCPSettings(server.name);
|
const result = await exportMCPSettings(server.name);
|
||||||
|
if (!result || !result.success || !result.data) {
|
||||||
|
showToast(result?.message || t('common.copyFailed') || 'Copy failed', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const configJson = JSON.stringify(result.data, null, 2);
|
const configJson = JSON.stringify(result.data, null, 2);
|
||||||
|
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { loadSettings, loadOriginalSettings } from '../config/index.js';
|
|||||||
import { getDataService } from '../services/services.js';
|
import { getDataService } from '../services/services.js';
|
||||||
import { DataService } from '../services/dataService.js';
|
import { DataService } from '../services/dataService.js';
|
||||||
import { IUser } from '../types/index.js';
|
import { IUser } from '../types/index.js';
|
||||||
|
import { getServerDao } from '../dao/DaoFactory.js';
|
||||||
|
|
||||||
const dataService: DataService = getDataService();
|
const dataService: DataService = getDataService();
|
||||||
|
|
||||||
@@ -73,17 +74,39 @@ export const getPublicConfig = (req: Request, res: Response): void => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively remove null values from an object
|
||||||
|
*/
|
||||||
|
const removeNullValues = <T>(obj: T): T => {
|
||||||
|
if (obj === null || obj === undefined) {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map((item) => removeNullValues(item)) as T;
|
||||||
|
}
|
||||||
|
if (typeof obj === 'object') {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
if (value !== null) {
|
||||||
|
result[key] = removeNullValues(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get MCP settings in JSON format for export/copy
|
* Get MCP settings in JSON format for export/copy
|
||||||
* Supports both full settings and individual server configuration
|
* Supports both full settings and individual server configuration
|
||||||
*/
|
*/
|
||||||
export const getMcpSettingsJson = (req: Request, res: Response): void => {
|
export const getMcpSettingsJson = async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { serverName } = req.query;
|
const { serverName } = req.query;
|
||||||
const settings = loadOriginalSettings();
|
|
||||||
if (serverName && typeof serverName === 'string') {
|
if (serverName && typeof serverName === 'string') {
|
||||||
// Return individual server configuration
|
// Return individual server configuration using DAO
|
||||||
const serverConfig = settings.mcpServers[serverName];
|
const serverDao = getServerDao();
|
||||||
|
const serverConfig = await serverDao.findById(serverName);
|
||||||
if (!serverConfig) {
|
if (!serverConfig) {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -92,16 +115,21 @@ export const getMcpSettingsJson = (req: Request, res: Response): void => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove the 'name' field from config as it's used as the key
|
||||||
|
const { name, ...configWithoutName } = serverConfig;
|
||||||
|
// Remove null values from the config
|
||||||
|
const cleanedConfig = removeNullValues(configWithoutName);
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
[serverName]: serverConfig,
|
[name]: cleanedConfig,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Return full settings
|
// Return full settings
|
||||||
|
const settings = loadOriginalSettings();
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: settings,
|
data: settings,
|
||||||
|
|||||||
@@ -1,33 +1,43 @@
|
|||||||
import { getMcpSettingsJson } from '../../src/controllers/configController.js'
|
import { getMcpSettingsJson } from '../../src/controllers/configController.js';
|
||||||
import * as config from '../../src/config/index.js'
|
import * as config from '../../src/config/index.js';
|
||||||
import { Request, Response } from 'express'
|
import * as DaoFactory from '../../src/dao/DaoFactory.js';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
|
||||||
// Mock the config module
|
// Mock the config module
|
||||||
jest.mock('../../src/config/index.js')
|
jest.mock('../../src/config/index.js');
|
||||||
|
// Mock the DaoFactory module
|
||||||
|
jest.mock('../../src/dao/DaoFactory.js');
|
||||||
|
|
||||||
describe('ConfigController - getMcpSettingsJson', () => {
|
describe('ConfigController - getMcpSettingsJson', () => {
|
||||||
let mockRequest: Partial<Request>
|
let mockRequest: Partial<Request>;
|
||||||
let mockResponse: Partial<Response>
|
let mockResponse: Partial<Response>;
|
||||||
let mockJson: jest.Mock
|
let mockJson: jest.Mock;
|
||||||
let mockStatus: jest.Mock
|
let mockStatus: jest.Mock;
|
||||||
|
let mockServerDao: { findById: jest.Mock };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockJson = jest.fn()
|
mockJson = jest.fn();
|
||||||
mockStatus = jest.fn().mockReturnThis()
|
mockStatus = jest.fn().mockReturnThis();
|
||||||
mockRequest = {
|
mockRequest = {
|
||||||
query: {},
|
query: {},
|
||||||
}
|
};
|
||||||
mockResponse = {
|
mockResponse = {
|
||||||
json: mockJson,
|
json: mockJson,
|
||||||
status: mockStatus,
|
status: mockStatus,
|
||||||
}
|
};
|
||||||
|
mockServerDao = {
|
||||||
|
findById: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup ServerDao mock
|
||||||
|
(DaoFactory.getServerDao as jest.Mock).mockReturnValue(mockServerDao);
|
||||||
|
|
||||||
// Reset mocks
|
// Reset mocks
|
||||||
jest.clearAllMocks()
|
jest.clearAllMocks();
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('Full Settings Export', () => {
|
describe('Full Settings Export', () => {
|
||||||
it('should handle settings without users array', () => {
|
it('should handle settings without users array', async () => {
|
||||||
const mockSettings = {
|
const mockSettings = {
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
'test-server': {
|
'test-server': {
|
||||||
@@ -35,11 +45,11 @@ describe('ConfigController - getMcpSettingsJson', () => {
|
|||||||
args: ['--test'],
|
args: ['--test'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
;(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings)
|
(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings);
|
||||||
|
|
||||||
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
|
await getMcpSettingsJson(mockRequest as Request, mockResponse as Response);
|
||||||
|
|
||||||
expect(mockJson).toHaveBeenCalledWith({
|
expect(mockJson).toHaveBeenCalledWith({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -47,40 +57,27 @@ describe('ConfigController - getMcpSettingsJson', () => {
|
|||||||
mcpServers: mockSettings.mcpServers,
|
mcpServers: mockSettings.mcpServers,
|
||||||
users: undefined,
|
users: undefined,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('Individual Server Export', () => {
|
describe('Individual Server Export', () => {
|
||||||
it('should return individual server configuration when serverName is specified', () => {
|
it('should return individual server configuration when serverName is specified', async () => {
|
||||||
const mockSettings = {
|
const serverConfig = {
|
||||||
mcpServers: {
|
name: 'test-server',
|
||||||
'test-server': {
|
|
||||||
command: 'test',
|
command: 'test',
|
||||||
args: ['--test'],
|
args: ['--test'],
|
||||||
env: {
|
env: {
|
||||||
TEST_VAR: 'test-value',
|
TEST_VAR: 'test-value',
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
'another-server': {
|
|
||||||
command: 'another',
|
|
||||||
args: ['--another'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
users: [
|
|
||||||
{
|
|
||||||
username: 'admin',
|
|
||||||
password: '$2b$10$hashedpassword',
|
|
||||||
isAdmin: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
mockRequest.query = { serverName: 'test-server' }
|
mockRequest.query = { serverName: 'test-server' };
|
||||||
;(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings)
|
mockServerDao.findById.mockResolvedValue(serverConfig);
|
||||||
|
|
||||||
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
|
await getMcpSettingsJson(mockRequest as Request, mockResponse as Response);
|
||||||
|
|
||||||
|
expect(mockServerDao.findById).toHaveBeenCalledWith('test-server');
|
||||||
expect(mockJson).toHaveBeenCalledWith({
|
expect(mockJson).toHaveBeenCalledWith({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -94,46 +91,73 @@ describe('ConfigController - getMcpSettingsJson', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should return 404 when server does not exist', () => {
|
it('should return 404 when server does not exist', async () => {
|
||||||
const mockSettings = {
|
mockRequest.query = { serverName: 'non-existent-server' };
|
||||||
|
mockServerDao.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await getMcpSettingsJson(mockRequest as Request, mockResponse as Response);
|
||||||
|
|
||||||
|
expect(mockServerDao.findById).toHaveBeenCalledWith('non-existent-server');
|
||||||
|
expect(mockStatus).toHaveBeenCalledWith(404);
|
||||||
|
expect(mockJson).toHaveBeenCalledWith({
|
||||||
|
success: false,
|
||||||
|
message: "Server 'non-existent-server' not found",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove null values from server configuration', async () => {
|
||||||
|
const serverConfig = {
|
||||||
|
name: 'test-server',
|
||||||
|
command: 'test',
|
||||||
|
args: ['--test'],
|
||||||
|
url: null,
|
||||||
|
env: null,
|
||||||
|
headers: null,
|
||||||
|
options: {
|
||||||
|
timeout: 30,
|
||||||
|
retries: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRequest.query = { serverName: 'test-server' };
|
||||||
|
mockServerDao.findById.mockResolvedValue(serverConfig);
|
||||||
|
|
||||||
|
await getMcpSettingsJson(mockRequest as Request, mockResponse as Response);
|
||||||
|
|
||||||
|
expect(mockJson).toHaveBeenCalledWith({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
'test-server': {
|
'test-server': {
|
||||||
command: 'test',
|
command: 'test',
|
||||||
args: ['--test'],
|
args: ['--test'],
|
||||||
|
options: {
|
||||||
|
timeout: 30,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
|
},
|
||||||
mockRequest.query = { serverName: 'non-existent-server' }
|
});
|
||||||
;(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings)
|
});
|
||||||
|
});
|
||||||
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
|
|
||||||
|
|
||||||
expect(mockStatus).toHaveBeenCalledWith(404)
|
|
||||||
expect(mockJson).toHaveBeenCalledWith({
|
|
||||||
success: false,
|
|
||||||
message: "Server 'non-existent-server' not found",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Error Handling', () => {
|
describe('Error Handling', () => {
|
||||||
it('should handle errors gracefully and return 500', () => {
|
it('should handle errors gracefully and return 500', async () => {
|
||||||
const errorMessage = 'Failed to load settings'
|
const errorMessage = 'Failed to load settings';
|
||||||
;(config.loadOriginalSettings as jest.Mock).mockImplementation(() => {
|
(config.loadOriginalSettings as jest.Mock).mockImplementation(() => {
|
||||||
throw new Error(errorMessage)
|
throw new Error(errorMessage);
|
||||||
})
|
});
|
||||||
|
|
||||||
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
|
await getMcpSettingsJson(mockRequest as Request, mockResponse as Response);
|
||||||
|
|
||||||
expect(mockStatus).toHaveBeenCalledWith(500)
|
expect(mockStatus).toHaveBeenCalledWith(500);
|
||||||
expect(mockJson).toHaveBeenCalledWith({
|
expect(mockJson).toHaveBeenCalledWith({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Failed to get MCP settings',
|
message: 'Failed to get MCP settings',
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user