mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-20 18:30:15 +01:00
✨ feat: Implement MCP Server Management API and UI Components
- Added API endpoints for managing MCP servers: create, read, update, and delete functionalities. - Introduced new UI components for MCP server configuration, including MCPFormPanel and MCPConfig. - Updated existing types and data provider to support MCP operations. - Enhanced the side panel to include MCP server management options. - Refactored related components and hooks for better integration with the new MCP features. - Added tests for the new MCP server API functionalities.
This commit is contained in:
parent
20100e120b
commit
351f30254c
26 changed files with 1189 additions and 290 deletions
|
|
@ -2,6 +2,7 @@
|
|||
export * from './mcp/manager';
|
||||
export * from './mcp/oauth';
|
||||
export * from './mcp/auth';
|
||||
export * from './mcp/servers';
|
||||
/* Utilities */
|
||||
export * from './mcp/utils';
|
||||
export * from './utils';
|
||||
|
|
|
|||
212
packages/api/src/mcp/servers.spec.ts
Normal file
212
packages/api/src/mcp/servers.spec.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import { Response } from 'express';
|
||||
import type { TUser } from 'librechat-data-provider';
|
||||
import { getMCPServers, createMCPServer, updateMCPServer, deleteMCPServer } from './servers';
|
||||
import type { AuthenticatedRequest, MCPRequest, MCPParamsRequest } from '../types';
|
||||
|
||||
describe('MCP Server Functions', () => {
|
||||
let mockReq: Partial<AuthenticatedRequest>;
|
||||
let mockRes: Partial<Response>;
|
||||
let mockUser: TUser;
|
||||
|
||||
beforeEach(() => {
|
||||
mockUser = { id: 'user123' } as TUser;
|
||||
|
||||
mockReq = { user: mockUser };
|
||||
mockRes = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getMCPServers', () => {
|
||||
it('should return mock MCP servers', async () => {
|
||||
await getMCPServers(mockReq as AuthenticatedRequest, mockRes as Response);
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
mcp_id: 'mcp_weather_001',
|
||||
metadata: expect.objectContaining({
|
||||
name: 'Weather Service',
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
mcp_id: 'mcp_calendar_002',
|
||||
metadata: expect.objectContaining({
|
||||
name: 'Calendar Manager',
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject unauthenticated requests', async () => {
|
||||
mockReq.user = undefined;
|
||||
|
||||
await getMCPServers(mockReq as AuthenticatedRequest, mockRes as Response);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(401);
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ message: 'User not authenticated' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMCPServer', () => {
|
||||
beforeEach(() => {
|
||||
mockReq.body = {
|
||||
mcp_id: 'mcp_test_123',
|
||||
metadata: {
|
||||
name: 'Test MCP Server',
|
||||
description: 'A test MCP server',
|
||||
url: 'http://localhost:3000',
|
||||
tools: ['test_tool'],
|
||||
icon: '🔧',
|
||||
trust: false,
|
||||
customHeaders: [],
|
||||
requestTimeout: 30000,
|
||||
connectionTimeout: 10000,
|
||||
},
|
||||
agent_id: '',
|
||||
};
|
||||
});
|
||||
|
||||
it('should create new MCP server from form data', async () => {
|
||||
await createMCPServer(mockReq as MCPRequest, mockRes as Response);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(201);
|
||||
expect(mockRes.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mcp_id: 'mcp_test_123',
|
||||
metadata: expect.objectContaining({
|
||||
name: 'Test MCP Server',
|
||||
description: 'A test MCP server',
|
||||
url: 'http://localhost:3000',
|
||||
tools: ['test_tool'],
|
||||
icon: '🔧',
|
||||
trust: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should prevent duplicate server names', async () => {
|
||||
mockReq.body = {
|
||||
metadata: {
|
||||
name: 'Weather Service', // This name already exists in mock data
|
||||
url: 'http://localhost:3000',
|
||||
},
|
||||
agent_id: '',
|
||||
};
|
||||
|
||||
await createMCPServer(mockReq as MCPRequest, mockRes as Response);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(409);
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ message: 'MCP server already exists' });
|
||||
});
|
||||
|
||||
it('should validate required fields', async () => {
|
||||
mockReq.body = {
|
||||
metadata: {
|
||||
description: 'A test MCP server',
|
||||
// Missing name and url
|
||||
},
|
||||
agent_id: '',
|
||||
};
|
||||
|
||||
await createMCPServer(mockReq as MCPRequest, mockRes as Response);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
message: 'Missing required fields: name and url are required',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateMCPServer', () => {
|
||||
beforeEach(() => {
|
||||
mockReq.body = {
|
||||
metadata: {
|
||||
name: 'Updated MCP Server',
|
||||
description: 'An updated MCP server',
|
||||
url: 'http://localhost:3001',
|
||||
tools: ['updated_tool'],
|
||||
icon: '⚙️',
|
||||
trust: true,
|
||||
customHeaders: [],
|
||||
requestTimeout: 45000,
|
||||
connectionTimeout: 15000,
|
||||
},
|
||||
agent_id: '',
|
||||
};
|
||||
mockReq.params = { mcp_id: 'mcp_weather_001' }; // Use existing mock server ID
|
||||
});
|
||||
|
||||
it('should update existing MCP server', async () => {
|
||||
await updateMCPServer(mockReq as MCPParamsRequest, mockRes as Response);
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mcp_id: 'mcp_weather_001',
|
||||
metadata: expect.objectContaining({
|
||||
name: 'Updated MCP Server',
|
||||
description: 'An updated MCP server',
|
||||
url: 'http://localhost:3001',
|
||||
tools: ['updated_tool'],
|
||||
icon: '⚙️',
|
||||
trust: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject updates to non-existent servers', async () => {
|
||||
mockReq.params = { mcp_id: 'non_existent_id' };
|
||||
|
||||
await updateMCPServer(mockReq as MCPParamsRequest, mockRes as Response);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(404);
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ message: 'MCP server not found' });
|
||||
});
|
||||
|
||||
it('should validate required fields', async () => {
|
||||
mockReq.body = {
|
||||
metadata: {
|
||||
description: 'An updated MCP server',
|
||||
// Missing name and url
|
||||
},
|
||||
agent_id: '',
|
||||
};
|
||||
|
||||
await updateMCPServer(mockReq as MCPParamsRequest, mockRes as Response);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
message: 'Missing required fields: name and url are required',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteMCPServer', () => {
|
||||
beforeEach(() => {
|
||||
mockReq.params = { mcp_id: 'mcp_weather_001' }; // Use existing mock server ID
|
||||
});
|
||||
|
||||
it('should delete existing MCP server', async () => {
|
||||
await deleteMCPServer(mockReq as MCPParamsRequest, mockRes as Response);
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ message: 'MCP server deleted successfully' });
|
||||
});
|
||||
|
||||
it('should reject deletion of non-existent servers', async () => {
|
||||
mockReq.params = { mcp_id: 'non_existent_id' };
|
||||
|
||||
await deleteMCPServer(mockReq as MCPParamsRequest, mockRes as Response);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(404);
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ message: 'MCP server not found' });
|
||||
});
|
||||
});
|
||||
});
|
||||
270
packages/api/src/mcp/servers.ts
Normal file
270
packages/api/src/mcp/servers.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import type { MCP } from 'librechat-data-provider';
|
||||
import type { Response } from 'express';
|
||||
import type { AuthenticatedRequest, MCPRequest, MCPParamsRequest } from '../types';
|
||||
|
||||
// Mock data for demonstration
|
||||
const mockMCPServers: MCP[] = [
|
||||
{
|
||||
mcp_id: 'mcp_weather_001',
|
||||
metadata: {
|
||||
name: 'Weather Service',
|
||||
description: 'Provides weather information and forecasts',
|
||||
url: 'https://weather-mcp.example.com',
|
||||
tools: ['get_current_weather', 'get_forecast', 'get_weather_alerts'],
|
||||
icon: '',
|
||||
trust: true,
|
||||
customHeaders: [],
|
||||
requestTimeout: 30000,
|
||||
connectionTimeout: 10000,
|
||||
},
|
||||
agent_id: '',
|
||||
},
|
||||
{
|
||||
mcp_id: 'mcp_calendar_002',
|
||||
metadata: {
|
||||
name: 'Calendar Manager',
|
||||
description: 'Manages calendar events and scheduling',
|
||||
url: 'https://calendar-mcp.example.com',
|
||||
tools: ['create_event', 'list_events', 'update_event', 'delete_event'],
|
||||
icon: '',
|
||||
trust: false,
|
||||
customHeaders: [{ id: '1', name: 'Authorization', value: 'Bearer {{api_key}}' }],
|
||||
requestTimeout: 45000,
|
||||
connectionTimeout: 15000,
|
||||
},
|
||||
agent_id: '',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get all MCP servers for the authenticated user
|
||||
*/
|
||||
export const getMCPServers = async (req: AuthenticatedRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
logger.warn('MCP servers fetch without user ID');
|
||||
res.status(401).json({ message: 'User not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Return mock MCP servers
|
||||
res.json(mockMCPServers);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching MCP servers:', error);
|
||||
res.status(500).json({ message: 'Failed to fetch MCP servers' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a single MCP server by ID
|
||||
*/
|
||||
export const getMCPServer = async (req: AuthenticatedRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { mcp_id } = req.params;
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
logger.warn('MCP server fetch without user ID');
|
||||
res.status(401).json({ message: 'User not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mcp_id) {
|
||||
logger.warn('MCP server fetch with missing mcp_id');
|
||||
res.status(400).json({ message: 'Missing required parameter: mcp_id' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the MCP server
|
||||
const server = mockMCPServers.find((s) => s.mcp_id === mcp_id);
|
||||
|
||||
if (!server) {
|
||||
logger.warn(`MCP server ${mcp_id} not found for user ${userId}`);
|
||||
res.status(404).json({ message: 'MCP server not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(server);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching MCP server:', error);
|
||||
res.status(500).json({ message: 'Failed to fetch MCP server' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new MCP server
|
||||
*/
|
||||
export const createMCPServer = async (req: MCPRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { body: formData } = req;
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
logger.warn('MCP server creation without user ID');
|
||||
res.status(401).json({ message: 'User not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!formData?.metadata?.name || !formData?.metadata?.url) {
|
||||
logger.warn('MCP server creation with missing required fields');
|
||||
res.status(400).json({
|
||||
message: 'Missing required fields: name and url are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if server already exists
|
||||
const serverExists = mockMCPServers.some(
|
||||
(server) => server.metadata.name === formData.metadata.name,
|
||||
);
|
||||
|
||||
if (serverExists) {
|
||||
logger.warn(`MCP server ${formData.metadata.name} already exists for user ${userId}`);
|
||||
res.status(409).json({ message: 'MCP server already exists' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new MCP server from form data
|
||||
const newMCPServer: MCP = {
|
||||
mcp_id: formData.mcp_id || `mcp_${Date.now()}`,
|
||||
metadata: {
|
||||
name: formData.metadata.name,
|
||||
description: formData.metadata.description || '',
|
||||
url: formData.metadata.url,
|
||||
tools: formData.metadata.tools || [],
|
||||
icon: formData.metadata.icon || '🔧',
|
||||
trust: formData.metadata.trust || false,
|
||||
customHeaders: formData.metadata.customHeaders || [],
|
||||
requestTimeout: formData.metadata.requestTimeout || 30000,
|
||||
connectionTimeout: formData.metadata.connectionTimeout || 10000,
|
||||
},
|
||||
agent_id: formData.agent_id || '',
|
||||
};
|
||||
|
||||
logger.info(`Created MCP server: ${newMCPServer.mcp_id} for user ${userId}`);
|
||||
|
||||
res.status(201).json(newMCPServer);
|
||||
} catch (error) {
|
||||
logger.error('Error creating MCP server:', error);
|
||||
res.status(500).json({ message: 'Failed to create MCP server' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update an existing MCP server
|
||||
*/
|
||||
export const updateMCPServer = async (req: MCPParamsRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const {
|
||||
body: formData,
|
||||
params: { mcp_id },
|
||||
} = req;
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
logger.warn('MCP server update without user ID');
|
||||
res.status(401).json({ message: 'User not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!formData?.metadata?.name || !formData?.metadata?.url) {
|
||||
logger.warn('MCP server update with missing required fields');
|
||||
res.status(400).json({
|
||||
message: 'Missing required fields: name and url are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mcp_id) {
|
||||
logger.warn('MCP server update with missing mcp_id');
|
||||
res.status(400).json({ message: 'Missing required parameter: mcp_id' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if server exists
|
||||
const existingServer = mockMCPServers.find((server) => server.mcp_id === mcp_id);
|
||||
|
||||
if (!existingServer) {
|
||||
logger.warn(`MCP server ${mcp_id} not found for update for user ${userId}`);
|
||||
res.status(404).json({ message: 'MCP server not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create updated MCP server from form data
|
||||
const updatedMCP: MCP = {
|
||||
mcp_id,
|
||||
metadata: {
|
||||
name: formData.metadata.name,
|
||||
description: formData.metadata.description || existingServer.metadata.description || '',
|
||||
url: formData.metadata.url,
|
||||
tools: formData.metadata.tools || existingServer.metadata.tools || [],
|
||||
icon: formData.metadata.icon || existingServer.metadata.icon || '🔧',
|
||||
trust:
|
||||
formData.metadata.trust !== undefined
|
||||
? formData.metadata.trust
|
||||
: existingServer.metadata.trust || false,
|
||||
customHeaders:
|
||||
formData.metadata.customHeaders || existingServer.metadata.customHeaders || [],
|
||||
requestTimeout:
|
||||
formData.metadata.requestTimeout || existingServer.metadata.requestTimeout || 30000,
|
||||
connectionTimeout:
|
||||
formData.metadata.connectionTimeout || existingServer.metadata.connectionTimeout || 10000,
|
||||
},
|
||||
agent_id: formData.agent_id || existingServer.agent_id || '',
|
||||
};
|
||||
|
||||
// In a real implementation, you would update this in a database
|
||||
logger.info(`Updated MCP server: ${mcp_id} for user ${userId}`);
|
||||
|
||||
res.json(updatedMCP);
|
||||
} catch (error) {
|
||||
logger.error('Error updating MCP server:', error);
|
||||
res.status(500).json({ message: 'Failed to update MCP server' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete an MCP server
|
||||
*/
|
||||
export const deleteMCPServer = async (req: MCPParamsRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const {
|
||||
params: { mcp_id },
|
||||
} = req;
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
logger.warn('MCP server deletion without user ID');
|
||||
res.status(401).json({ message: 'User not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mcp_id) {
|
||||
logger.warn('MCP server deletion with missing mcp_id');
|
||||
res.status(400).json({ message: 'Missing required parameter: mcp_id' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if server exists
|
||||
const serverExists = mockMCPServers.some((server) => server.mcp_id === mcp_id);
|
||||
|
||||
if (!serverExists) {
|
||||
logger.warn(`MCP server ${mcp_id} not found for deletion for user ${userId}`);
|
||||
res.status(404).json({ message: 'MCP server not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// In a real implementation, you would delete this from a database
|
||||
logger.info(`Deleted MCP server: ${mcp_id} for user ${userId}`);
|
||||
|
||||
res.json({ message: 'MCP server deleted successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Error deleting MCP server:', error);
|
||||
res.status(500).json({ message: 'Failed to delete MCP server' });
|
||||
}
|
||||
};
|
||||
|
|
@ -2,5 +2,6 @@ export * from './azure';
|
|||
export * from './events';
|
||||
export * from './google';
|
||||
export * from './mistral';
|
||||
export * from './mcp';
|
||||
export * from './openai';
|
||||
export * from './run';
|
||||
|
|
|
|||
17
packages/api/src/types/mcp.ts
Normal file
17
packages/api/src/types/mcp.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { TUser, MCP } from 'librechat-data-provider';
|
||||
import type { Request } from 'express';
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: TUser;
|
||||
}
|
||||
|
||||
export interface MCPRequest extends AuthenticatedRequest {
|
||||
body: MCP;
|
||||
}
|
||||
|
||||
export interface MCPParamsRequest extends AuthenticatedRequest {
|
||||
params: {
|
||||
mcp_id: string;
|
||||
};
|
||||
body: MCP;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue