Add OpenAPI support and enhance settings aggregation (#500)

This commit is contained in:
samanhappy
2025-12-11 17:42:50 +08:00
committed by GitHub
parent 1dfa0a990b
commit 2028233b53
8 changed files with 274 additions and 74 deletions

View File

@@ -14,6 +14,10 @@ interface McpServerConfig {
type?: string;
url?: string;
headers?: Record<string, string>;
openapi?: {
version: string;
url: string;
};
}
interface ImportJsonFormat {
@@ -46,6 +50,12 @@ const JSONImportForm: React.FC<JSONImportFormProps> = ({ onSuccess, onCancel })
"Content-Type": "application/json",
"Authorization": "Bearer your-token"
}
},
"openapi-server-example": {
"type": "openapi",
"openapi": {
"url": "https://petstore.swagger.io/v2/swagger.json"
}
}
}
}
@@ -85,6 +95,9 @@ All servers will be imported in a single efficient batch operation.`;
if (config.headers) {
normalizedConfig.headers = config.headers;
}
} else if (config.type === 'openapi') {
normalizedConfig.type = 'openapi';
normalizedConfig.openapi = config.openapi;
} else {
// Default to stdio
normalizedConfig.type = 'stdio';

View File

@@ -21,7 +21,14 @@ interface DynamicFormProps {
title?: string; // Optional title to display instead of default parameters title
}
const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, loading = false, storageKey, title }) => {
const DynamicForm: React.FC<DynamicFormProps> = ({
schema,
onSubmit,
onCancel,
loading = false,
storageKey,
title,
}) => {
const { t } = useTranslation();
const [formValues, setFormValues] = useState<Record<string, any>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
@@ -40,9 +47,14 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
description: obj.description,
enum: obj.enum,
default: obj.default,
properties: obj.properties ? Object.fromEntries(
Object.entries(obj.properties).map(([key, value]) => [key, convertProperty(value)])
) : undefined,
properties: obj.properties
? Object.fromEntries(
Object.entries(obj.properties).map(([key, value]) => [
key,
convertProperty(value),
]),
)
: undefined,
required: obj.required,
items: obj.items ? convertProperty(obj.items) : undefined,
};
@@ -52,9 +64,14 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
return {
type: schema.type,
properties: schema.properties ? Object.fromEntries(
Object.entries(schema.properties).map(([key, value]) => [key, convertProperty(value)])
) : undefined,
properties: schema.properties
? Object.fromEntries(
Object.entries(schema.properties).map(([key, value]) => [
key,
convertProperty(value),
]),
)
: undefined,
required: schema.required,
};
};
@@ -167,7 +184,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
};
const handleInputChange = (path: string, value: any) => {
setFormValues(prev => {
setFormValues((prev) => {
const newValues = { ...prev };
const keys = path.split('.');
let current = newValues;
@@ -195,7 +212,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
// Clear error for this field
if (errors[path]) {
setErrors(prev => {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[path];
return newErrors;
@@ -209,10 +226,16 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
if (schema.type === 'object' && schema.properties) {
Object.entries(schema.properties).forEach(([key, propSchema]) => {
const fullPath = path ? `${path}.${key}` : key;
const value = getNestedValue(values, fullPath);
const value = values?.[key];
// Check required fields
if (schema.required?.includes(key) && (value === undefined || value === null || value === '')) {
if (
schema.required?.includes(key) &&
(value === undefined ||
value === null ||
value === '' ||
(Array.isArray(value) && value.length === 0))
) {
newErrors[fullPath] = `${key} is required`;
return;
}
@@ -223,7 +246,10 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
newErrors[fullPath] = `${key} must be a string`;
} else if (propSchema.type === 'number' && typeof value !== 'number') {
newErrors[fullPath] = `${key} must be a number`;
} else if (propSchema.type === 'integer' && (!Number.isInteger(value) || typeof value !== 'number')) {
} else if (
propSchema.type === 'integer' &&
(!Number.isInteger(value) || typeof value !== 'number')
) {
newErrors[fullPath] = `${key} must be an integer`;
} else if (propSchema.type === 'boolean' && typeof value !== 'boolean') {
newErrors[fullPath] = `${key} must be a boolean`;
@@ -260,7 +286,12 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
return path.split('.').reduce((current, key) => current?.[key], obj);
};
const renderObjectField = (key: string, schema: JsonSchema, currentValue: any, onChange: (value: any) => void): React.ReactNode => {
const renderObjectField = (
key: string,
schema: JsonSchema,
currentValue: any,
onChange: (value: any) => void,
): React.ReactNode => {
const value = currentValue?.[key];
if (schema.type === 'string') {
@@ -299,7 +330,12 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
step={schema.type === 'integer' ? '1' : 'any'}
value={value ?? ''}
onChange={(e) => {
const val = e.target.value === '' ? '' : schema.type === 'integer' ? parseInt(e.target.value) : parseFloat(e.target.value);
const val =
e.target.value === ''
? ''
: schema.type === 'integer'
? parseInt(e.target.value)
: parseFloat(e.target.value);
onChange(val);
}}
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500 form-input"
@@ -333,7 +369,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
const renderField = (key: string, propSchema: JsonSchema, path: string = ''): React.ReactNode => {
const fullPath = path ? `${path}.${key}` : key;
const value = getNestedValue(formValues, fullPath);
const error = errors[fullPath]; // Handle array type
const error = errors[fullPath]; // Handle array type
if (propSchema.type === 'array') {
const arrayValue = getNestedValue(formValues, fullPath) || [];
@@ -341,7 +377,11 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
{(path
? getNestedValue(jsonSchema, path)?.required?.includes(key)
: jsonSchema.required?.includes(key)) && (
<span className="text-status-red ml-1">*</span>
)}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -349,9 +389,11 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div className="border border-gray-200 rounded-md p-3 bg-gray-50">
{arrayValue.map((item: any, index: number) => (
<div key={index} className="mb-3 p-3 bg-white border rounded-md">
<div key={index} className="mb-3 p-3 bg-white border border-gray-200 rounded-md">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-gray-600">{t('tool.item', { index: index + 1 })}</span>
<span className="text-sm font-medium text-gray-600">
{t('tool.item', { index: index + 1 })}
</span>
<button
type="button"
onClick={() => {
@@ -388,7 +430,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={objKey}>
<label className="block text-xs font-medium text-gray-600 mb-1">
{objKey}
{propSchema.items?.required?.includes(objKey) && <span className="text-status-red ml-1">*</span>}
{propSchema.items?.required?.includes(objKey) && (
<span className="text-status-red ml-1">*</span>
)}
</label>
{renderObjectField(objKey, objSchema as JsonSchema, item, (newValue) => {
const newArray = [...arrayValue];
@@ -429,7 +473,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
} // Handle object type
} // Handle object type
if (propSchema.type === 'object') {
if (propSchema.properties) {
// Object with defined properties - render as nested form
@@ -437,16 +481,20 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
{(path
? getNestedValue(jsonSchema, path)?.required?.includes(key)
: jsonSchema.required?.includes(key)) && (
<span className="text-status-red ml-1">*</span>
)}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
)}
<div className="border border-gray-200 rounded-md p-4 bg-gray-50">
{Object.entries(propSchema.properties).map(([objKey, objSchema]) => (
renderField(objKey, objSchema as JsonSchema, fullPath)
))}
{Object.entries(propSchema.properties).map(([objKey, objSchema]) =>
renderField(objKey, objSchema as JsonSchema, fullPath),
)}
</div>
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
@@ -458,7 +506,11 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
{(path
? getNestedValue(jsonSchema, path)?.required?.includes(key)
: jsonSchema.required?.includes(key)) && (
<span className="text-status-red ml-1">*</span>
)}
<span className="text-xs text-gray-500 ml-1">(JSON object)</span>
</label>
{propSchema.description && (
@@ -483,13 +535,16 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
</div>
);
}
} if (propSchema.type === 'string') {
}
if (propSchema.type === 'string') {
if (propSchema.enum) {
return (
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && (
<span className="text-status-red ml-1">*</span>
)}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -514,7 +569,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && (
<span className="text-status-red ml-1">*</span>
)}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -529,12 +586,15 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
</div>
);
}
} if (propSchema.type === 'number' || propSchema.type === 'integer') {
}
if (propSchema.type === 'number' || propSchema.type === 'integer') {
return (
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && (
<span className="text-status-red ml-1">*</span>
)}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -544,7 +604,12 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
step={propSchema.type === 'integer' ? '1' : 'any'}
value={value !== undefined && value !== null ? value : ''}
onChange={(e) => {
const val = e.target.value === '' ? '' : propSchema.type === 'integer' ? parseInt(e.target.value) : parseFloat(e.target.value);
const val =
e.target.value === ''
? ''
: propSchema.type === 'integer'
? parseInt(e.target.value)
: parseFloat(e.target.value);
handleInputChange(fullPath, val);
}}
className={`w-full border rounded-md px-3 py-2 form-input ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
@@ -566,7 +631,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
/>
<label className="ml-2 block text-sm text-gray-700">
{key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && (
<span className="text-status-red ml-1">*</span>
)}
</label>
</div>
{propSchema.description && (
@@ -575,12 +642,14 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
} // For other types, show as text input with description
} // For other types, show as text input with description
return (
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && (
<span className="text-status-red ml-1">*</span>
)}
<span className="text-xs text-gray-500 ml-1">({propSchema.type})</span>
</label>
{propSchema.description && (
@@ -631,20 +700,22 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<button
type="button"
onClick={switchToFormMode}
className={`px-3 py-1 text-sm rounded-md transition-colors ${!isJsonMode
? 'bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
}`}
className={`px-3 py-1 text-sm rounded-md transition-colors ${
!isJsonMode
? 'bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
}`}
>
{t('tool.formMode')}
</button>
<button
type="button"
onClick={switchToJsonMode}
className={`px-3 py-1 text-sm rounded-md transition-colors ${isJsonMode
? 'px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
}`}
className={`px-3 py-1 text-sm rounded-md transition-colors ${
isJsonMode
? 'px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
}`}
>
{t('tool.jsonMode')}
</button>
@@ -662,8 +733,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
value={jsonText}
onChange={(e) => handleJsonTextChange(e.target.value)}
placeholder={`{\n "key": "value"\n}`}
className={`w-full h-64 border rounded-md px-3 py-2 font-mono text-sm resize-y form-input ${jsonError ? 'border-red-500' : 'border-gray-300'
} focus:outline-none focus:ring-2 focus:ring-blue-500`}
className={`w-full h-64 border rounded-md px-3 py-2 font-mono text-sm resize-y form-input ${
jsonError ? 'border-red-500' : 'border-gray-300'
} focus:outline-none focus:ring-2 focus:ring-blue-500`}
/>
{jsonError && <p className="text-status-red text-xs mt-1">{jsonError}</p>}
</div>
@@ -696,7 +768,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
/* Form Mode */
<form onSubmit={handleSubmit} className="space-y-4">
{Object.entries(jsonSchema.properties || {}).map(([key, propSchema]) =>
renderField(key, propSchema)
renderField(key, propSchema),
)}
<div className="flex justify-end space-x-2 pt-4">

View File

@@ -1,10 +1,18 @@
import { Request, Response } from 'express';
import config from '../config/index.js';
import { loadSettings, loadOriginalSettings } from '../config/index.js';
import { loadSettings } from '../config/index.js';
import { getDataService } from '../services/services.js';
import { DataService } from '../services/dataService.js';
import { IUser } from '../types/index.js';
import { getServerDao } from '../dao/DaoFactory.js';
import {
getGroupDao,
getOAuthClientDao,
getOAuthTokenDao,
getServerDao,
getSystemConfigDao,
getUserConfigDao,
getUserDao,
} from '../dao/DaoFactory.js';
const dataService: DataService = getDataService();
@@ -128,8 +136,33 @@ export const getMcpSettingsJson = async (req: Request, res: Response): Promise<v
},
});
} else {
// Return full settings
const settings = loadOriginalSettings();
// Return full settings via DAO layer (supports both file and database modes)
const [servers, users, groups, systemConfig, userConfigs, oauthClients, oauthTokens] =
await Promise.all([
getServerDao().findAll(),
getUserDao().findAll(),
getGroupDao().findAll(),
getSystemConfigDao().get(),
getUserConfigDao().getAll(),
getOAuthClientDao().findAll(),
getOAuthTokenDao().findAll(),
]);
const mcpServers: Record<string, any> = {};
for (const { name: serverConfigName, ...config } of servers) {
mcpServers[serverConfigName] = removeNullValues(config);
}
const settings = {
mcpServers,
users,
groups,
systemConfig,
userConfigs,
oauthClients,
oauthTokens,
};
res.json({
success: true,
data: settings,

View File

@@ -38,6 +38,7 @@ export class ServerDaoDbImpl implements ServerDao {
prompts: entity.prompts,
options: entity.options,
oauth: entity.oauth,
openapi: entity.openapi,
});
return this.mapToServerConfig(server);
}
@@ -61,6 +62,7 @@ export class ServerDaoDbImpl implements ServerDao {
prompts: entity.prompts,
options: entity.options,
oauth: entity.oauth,
openapi: entity.openapi,
});
return server ? this.mapToServerConfig(server) : null;
}
@@ -129,6 +131,7 @@ export class ServerDaoDbImpl implements ServerDao {
prompts?: Record<string, { enabled: boolean; description?: string }>;
options?: Record<string, any>;
oauth?: Record<string, any>;
openapi?: Record<string, any>;
}): ServerConfigWithName {
return {
name: server.name,
@@ -146,6 +149,7 @@ export class ServerDaoDbImpl implements ServerDao {
prompts: server.prompts,
options: server.options,
oauth: server.oauth,
openapi: server.openapi,
};
}
}

View File

@@ -59,6 +59,9 @@ export class Server {
@Column({ type: 'simple-json', nullable: true })
oauth?: Record<string, any>;
@Column({ type: 'simple-json', nullable: true })
openapi?: Record<string, any>;
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;

View File

@@ -614,9 +614,37 @@ export const registerAllTools = async (isInit: boolean, serverName?: string): Pr
export const getServersInfo = async (): Promise<Omit<ServerInfo, 'client' | 'transport'>[]> => {
const allServers: ServerConfigWithName[] = await getServerDao().findAll();
const dataService = getDataService();
// Ensure that servers recently added via DAO but not yet initialized in serverInfos
// are still visible in the servers list. This avoids a race condition where
// a POST /api/servers immediately followed by GET /api/servers would not
// return the newly created server until background initialization completes.
const combinedServerInfos: ServerInfo[] = [...serverInfos];
const existingNames = new Set(combinedServerInfos.map((s) => s.name));
for (const server of allServers) {
if (!existingNames.has(server.name)) {
const isEnabled = server.enabled === undefined ? true : server.enabled;
combinedServerInfos.push({
name: server.name,
owner: server.owner,
// Newly created servers that are enabled should appear as "connecting"
// until the MCP client initialization completes. Disabled servers remain
// in the "disconnected" state.
status: isEnabled ? 'connecting' : 'disconnected',
error: null,
tools: [],
prompts: [],
createTime: Date.now(),
enabled: isEnabled,
});
}
}
const filterServerInfos: ServerInfo[] = dataService.filterData
? dataService.filterData(serverInfos)
: serverInfos;
? dataService.filterData(combinedServerInfos)
: combinedServerInfos;
const infos = filterServerInfos.map(
({ name, status, tools, prompts, createTime, error, oauth }) => {
const serverConfig = allServers.find((server) => server.name === name);

View File

@@ -75,6 +75,7 @@ export async function migrateToDatabase(): Promise<boolean> {
prompts: config.prompts,
options: config.options,
oauth: config.oauth,
openapi: config.openapi,
});
console.log(` - Created server: ${name}`);
} else {

View File

@@ -1,10 +1,7 @@
import { getMcpSettingsJson } from '../../src/controllers/configController.js';
import * as config from '../../src/config/index.js';
import * as DaoFactory from '../../src/dao/DaoFactory.js';
import { Request, Response } from 'express';
// Mock the config module
jest.mock('../../src/config/index.js');
// Mock the DaoFactory module
jest.mock('../../src/dao/DaoFactory.js');
@@ -13,9 +10,17 @@ describe('ConfigController - getMcpSettingsJson', () => {
let mockResponse: Partial<Response>;
let mockJson: jest.Mock;
let mockStatus: jest.Mock;
let mockServerDao: { findById: jest.Mock };
let mockServerDao: { findById: jest.Mock; findAll: jest.Mock };
let mockUserDao: { findAll: jest.Mock };
let mockGroupDao: { findAll: jest.Mock };
let mockSystemConfigDao: { get: jest.Mock };
let mockUserConfigDao: { getAll: jest.Mock };
let mockOAuthClientDao: { findAll: jest.Mock };
let mockOAuthTokenDao: { findAll: jest.Mock };
beforeEach(() => {
jest.clearAllMocks();
mockJson = jest.fn();
mockStatus = jest.fn().mockReturnThis();
mockRequest = {
@@ -27,35 +32,73 @@ describe('ConfigController - getMcpSettingsJson', () => {
};
mockServerDao = {
findById: jest.fn(),
findAll: jest.fn(),
};
mockUserDao = { findAll: jest.fn() };
mockGroupDao = { findAll: jest.fn() };
mockSystemConfigDao = { get: jest.fn() };
mockUserConfigDao = { getAll: jest.fn() };
mockOAuthClientDao = { findAll: jest.fn() };
mockOAuthTokenDao = { findAll: jest.fn() };
// Setup ServerDao mock
(DaoFactory.getServerDao as jest.Mock).mockReturnValue(mockServerDao);
// Reset mocks
jest.clearAllMocks();
(DaoFactory.getUserDao as jest.Mock).mockReturnValue(mockUserDao);
(DaoFactory.getGroupDao as jest.Mock).mockReturnValue(mockGroupDao);
(DaoFactory.getSystemConfigDao as jest.Mock).mockReturnValue(mockSystemConfigDao);
(DaoFactory.getUserConfigDao as jest.Mock).mockReturnValue(mockUserConfigDao);
(DaoFactory.getOAuthClientDao as jest.Mock).mockReturnValue(mockOAuthClientDao);
(DaoFactory.getOAuthTokenDao as jest.Mock).mockReturnValue(mockOAuthTokenDao);
});
describe('Full Settings Export', () => {
it('should handle settings without users array', async () => {
const mockSettings = {
mcpServers: {
'test-server': {
command: 'test',
args: ['--test'],
},
it('should return settings aggregated from DAOs', async () => {
mockServerDao.findAll.mockResolvedValue([
{ name: 'server-a', command: 'node', args: ['index.js'], env: { A: '1' } },
{ name: 'server-b', command: 'npx', args: ['run'], env: null },
]);
mockUserDao.findAll.mockResolvedValue([
{ username: 'admin', password: 'hash', isAdmin: true },
]);
mockGroupDao.findAll.mockResolvedValue([{ id: 'g1', name: 'Group', servers: [] }]);
mockSystemConfigDao.get.mockResolvedValue({ routing: { skipAuth: false } });
mockUserConfigDao.getAll.mockResolvedValue({ admin: { routing: {} } });
mockOAuthClientDao.findAll.mockResolvedValue([
{ clientId: 'c1', clientSecret: 's', name: 'client' },
]);
mockOAuthTokenDao.findAll.mockResolvedValue([
{
accessToken: 'a',
accessTokenExpiresAt: new Date('2024-01-01T00:00:00Z'),
clientId: 'c1',
username: 'admin',
},
};
(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings);
]);
await getMcpSettingsJson(mockRequest as Request, mockResponse as Response);
expect(mockServerDao.findAll).toHaveBeenCalled();
expect(mockUserDao.findAll).toHaveBeenCalled();
expect(mockJson).toHaveBeenCalledWith({
success: true,
data: {
mcpServers: mockSettings.mcpServers,
users: undefined,
mcpServers: {
'server-a': { command: 'node', args: ['index.js'], env: { A: '1' } },
'server-b': { command: 'npx', args: ['run'] },
},
users: [{ username: 'admin', password: 'hash', isAdmin: true }],
groups: [{ id: 'g1', name: 'Group', servers: [] }],
systemConfig: { routing: { skipAuth: false } },
userConfigs: { admin: { routing: {} } },
oauthClients: [{ clientId: 'c1', clientSecret: 's', name: 'client' }],
oauthTokens: [
{
accessToken: 'a',
accessTokenExpiresAt: new Date('2024-01-01T00:00:00Z'),
clientId: 'c1',
username: 'admin',
},
],
},
});
});
@@ -146,10 +189,13 @@ describe('ConfigController - getMcpSettingsJson', () => {
describe('Error Handling', () => {
it('should handle errors gracefully and return 500', async () => {
const errorMessage = 'Failed to load settings';
(config.loadOriginalSettings as jest.Mock).mockImplementation(() => {
throw new Error(errorMessage);
});
mockServerDao.findAll.mockRejectedValue(new Error('boom'));
mockUserDao.findAll.mockResolvedValue([]);
mockGroupDao.findAll.mockResolvedValue([]);
mockSystemConfigDao.get.mockResolvedValue({});
mockUserConfigDao.getAll.mockResolvedValue({});
mockOAuthClientDao.findAll.mockResolvedValue([]);
mockOAuthTokenDao.findAll.mockResolvedValue([]);
await getMcpSettingsJson(mockRequest as Request, mockResponse as Response);