diff --git a/frontend/src/components/JSONImportForm.tsx b/frontend/src/components/JSONImportForm.tsx index e430079..36d5da0 100644 --- a/frontend/src/components/JSONImportForm.tsx +++ b/frontend/src/components/JSONImportForm.tsx @@ -14,6 +14,10 @@ interface McpServerConfig { type?: string; url?: string; headers?: Record; + openapi?: { + version: string; + url: string; + }; } interface ImportJsonFormat { @@ -46,6 +50,12 @@ const JSONImportForm: React.FC = ({ 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'; diff --git a/frontend/src/components/ui/DynamicForm.tsx b/frontend/src/components/ui/DynamicForm.tsx index db4970d..4564efe 100644 --- a/frontend/src/components/ui/DynamicForm.tsx +++ b/frontend/src/components/ui/DynamicForm.tsx @@ -21,7 +21,14 @@ interface DynamicFormProps { title?: string; // Optional title to display instead of default parameters title } -const DynamicForm: React.FC = ({ schema, onSubmit, onCancel, loading = false, storageKey, title }) => { +const DynamicForm: React.FC = ({ + schema, + onSubmit, + onCancel, + loading = false, + storageKey, + title, +}) => { const { t } = useTranslation(); const [formValues, setFormValues] = useState>({}); const [errors, setErrors] = useState>({}); @@ -40,9 +47,14 @@ const DynamicForm: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ schema, onSubmit, onCancel, l
{propSchema.description && (

{propSchema.description}

@@ -349,9 +389,11 @@ const DynamicForm: React.FC = ({ schema, onSubmit, onCancel, l
{arrayValue.map((item: any, index: number) => ( -
+
- {t('tool.item', { index: index + 1 })} + + {t('tool.item', { index: index + 1 })} + @@ -662,8 +733,9 @@ const DynamicForm: React.FC = ({ 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 &&

{jsonError}

}
@@ -696,7 +768,7 @@ const DynamicForm: React.FC = ({ schema, onSubmit, onCancel, l /* Form Mode */
{Object.entries(jsonSchema.properties || {}).map(([key, propSchema]) => - renderField(key, propSchema) + renderField(key, propSchema), )}
diff --git a/src/controllers/configController.ts b/src/controllers/configController.ts index 318a57e..1537cd3 100644 --- a/src/controllers/configController.ts +++ b/src/controllers/configController.ts @@ -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 = {}; + 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, diff --git a/src/dao/ServerDaoDbImpl.ts b/src/dao/ServerDaoDbImpl.ts index 8f508f8..6ba9a74 100644 --- a/src/dao/ServerDaoDbImpl.ts +++ b/src/dao/ServerDaoDbImpl.ts @@ -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; options?: Record; oauth?: Record; + openapi?: Record; }): 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, }; } } diff --git a/src/db/entities/Server.ts b/src/db/entities/Server.ts index 9b451a1..bfdbb0c 100644 --- a/src/db/entities/Server.ts +++ b/src/db/entities/Server.ts @@ -59,6 +59,9 @@ export class Server { @Column({ type: 'simple-json', nullable: true }) oauth?: Record; + @Column({ type: 'simple-json', nullable: true }) + openapi?: Record; + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) createdAt: Date; diff --git a/src/services/mcpService.ts b/src/services/mcpService.ts index 32aedb5..2f25a4d 100644 --- a/src/services/mcpService.ts +++ b/src/services/mcpService.ts @@ -614,9 +614,37 @@ export const registerAllTools = async (isInit: boolean, serverName?: string): Pr export const getServersInfo = async (): Promise[]> => { 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); diff --git a/src/utils/migration.ts b/src/utils/migration.ts index a7e3a78..9fc1c01 100644 --- a/src/utils/migration.ts +++ b/src/utils/migration.ts @@ -75,6 +75,7 @@ export async function migrateToDatabase(): Promise { prompts: config.prompts, options: config.options, oauth: config.oauth, + openapi: config.openapi, }); console.log(` - Created server: ${name}`); } else { diff --git a/tests/controllers/configController.test.ts b/tests/controllers/configController.test.ts index 34d27d0..f219ec8 100644 --- a/tests/controllers/configController.test.ts +++ b/tests/controllers/configController.test.ts @@ -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; 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);