mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
refactor
This commit is contained in:
44
src/config/index.ts
Normal file
44
src/config/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { McpSettings } from '../types/index.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const defaultConfig = {
|
||||
port: process.env.PORT || 3000,
|
||||
mcpHubName: 'mcphub',
|
||||
mcpHubVersion: '0.0.1',
|
||||
};
|
||||
|
||||
export const getSettingsPath = (): string => {
|
||||
return path.resolve(process.cwd(), 'mcp_settings.json');
|
||||
};
|
||||
|
||||
export const loadSettings = (): McpSettings => {
|
||||
const settingsPath = getSettingsPath();
|
||||
try {
|
||||
const settingsData = fs.readFileSync(settingsPath, 'utf8');
|
||||
return JSON.parse(settingsData);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load settings from ${settingsPath}:`, error);
|
||||
return { mcpServers: {} };
|
||||
}
|
||||
};
|
||||
|
||||
export const saveSettings = (settings: McpSettings): boolean => {
|
||||
const settingsPath = getSettingsPath();
|
||||
try {
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to save settings to ${settingsPath}:`, error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const expandEnvVars = (value: string): string => {
|
||||
return value.replace(/\$\{([^}]+)\}/g, (_, key) => process.env[key] || '');
|
||||
};
|
||||
|
||||
export default defaultConfig;
|
||||
124
src/controllers/serverController.ts
Normal file
124
src/controllers/serverController.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiResponse, AddServerRequest } from '../types/index.js';
|
||||
import { getServersInfo, addServer, removeServer } from '../services/mcpService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
|
||||
let mcpServerInstance: any;
|
||||
|
||||
export const setMcpServerInstance = (server: any): void => {
|
||||
mcpServerInstance = server;
|
||||
};
|
||||
|
||||
export const getAllServers = (_: Request, res: Response): void => {
|
||||
try {
|
||||
const serversInfo = getServersInfo();
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: serversInfo
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get servers information'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getAllSettings = (_: Request, res: Response): void => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: settings
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get server settings'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const createServer = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { name, config } = req.body as AddServerRequest;
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server name is required'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config || typeof config !== 'object') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server configuration is required'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.url && (!config.command || !config.args)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server configuration must include either a URL or command with arguments'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await addServer(mcpServerInstance, name, config);
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Server added successfully'
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: result.message || 'Failed to add server'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteServer = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
if (!name) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server name is required'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = removeServer(name);
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Server removed successfully'
|
||||
});
|
||||
} else {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: result.message || 'Server not found or failed to remove'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error'
|
||||
});
|
||||
}
|
||||
};
|
||||
141
src/index.ts
141
src/index.ts
@@ -1,134 +1,17 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import dotenv from 'dotenv';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import {
|
||||
registerAllTools,
|
||||
getServersInfo,
|
||||
getServersSettings,
|
||||
addServer,
|
||||
removeServer,
|
||||
} from './server.js';
|
||||
import path from 'path';
|
||||
import AppServer from './server.js';
|
||||
|
||||
dotenv.config();
|
||||
const appServer = new AppServer();
|
||||
|
||||
let server = new McpServer({
|
||||
name: 'mcphub',
|
||||
version: '0.0.1',
|
||||
});
|
||||
|
||||
// Register all MCP tools from the modular structure
|
||||
registerAllTools(server);
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Serve static files from the public directory
|
||||
app.use(express.static('public'));
|
||||
|
||||
// Add conditional JSON parsing middleware
|
||||
app.use((req, res, next) => {
|
||||
if (req.path !== '/sse' && req.path !== '/messages') {
|
||||
express.json()(req, res, next);
|
||||
} else {
|
||||
next();
|
||||
async function boot() {
|
||||
try {
|
||||
await appServer.initialize();
|
||||
appServer.start();
|
||||
} catch (error) {
|
||||
console.error('Failed to start application:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// to support multiple simultaneous connections we have a lookup object from sessionId to transport
|
||||
const transports: { [sessionId: string]: SSEServerTransport } = {};
|
||||
boot();
|
||||
|
||||
// API endpoint to get server and tools information
|
||||
app.get('/api/servers', (req: Request, res: Response) => {
|
||||
const serversInfo = getServersInfo();
|
||||
res.json(serversInfo);
|
||||
});
|
||||
|
||||
// API endpoint to get all server settings
|
||||
app.get('/api/settings', (req: Request, res: Response) => {
|
||||
const settings = getServersSettings();
|
||||
res.json(settings);
|
||||
});
|
||||
|
||||
// API endpoint to add a new server
|
||||
app.post('/api/servers', async (req: Request, res: Response) => {
|
||||
const { name, config } = req.body;
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
return res.status(400).json({ success: false, message: 'Server name is required' });
|
||||
}
|
||||
|
||||
if (!config || typeof config !== 'object') {
|
||||
return res.status(400).json({ success: false, message: 'Server configuration is required' });
|
||||
}
|
||||
|
||||
// Validate config has either url or command+args
|
||||
if (!config.url && (!config.command || !config.args)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server configuration must include either a URL or command with arguments',
|
||||
});
|
||||
}
|
||||
|
||||
const { success, message } = await addServer(server, name, config);
|
||||
if (success) {
|
||||
res.json({ success: true, message: 'Server added successfully' });
|
||||
} else {
|
||||
res.status(400).json({ success: false, message: message || 'Failed to add server' });
|
||||
}
|
||||
});
|
||||
|
||||
// API endpoint to remove a server
|
||||
app.delete('/api/servers/:name', async (req: Request, res: Response) => {
|
||||
const { name } = req.params;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ success: false, message: 'Server name is required' });
|
||||
}
|
||||
|
||||
const result = removeServer(name);
|
||||
if (result.success) {
|
||||
server = new McpServer({
|
||||
name: 'mcphub',
|
||||
version: '0.0.1',
|
||||
});
|
||||
|
||||
registerAllTools(server);
|
||||
|
||||
res.json({ success: true, message: 'Server removed successfully' });
|
||||
} else {
|
||||
res.status(404).json({ success: false, message: 'Server not found or failed to remove' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/sse', async (_: Request, res: Response) => {
|
||||
const transport = new SSEServerTransport('/messages', res);
|
||||
transports[transport.sessionId] = transport;
|
||||
res.on('close', () => {
|
||||
delete transports[transport.sessionId];
|
||||
});
|
||||
await server.connect(transport);
|
||||
});
|
||||
|
||||
// Serve index.html for the root route
|
||||
app.get('/', (req: Request, res: Response) => {
|
||||
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
|
||||
});
|
||||
|
||||
app.post('/messages', async (req: Request, res: Response) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const transport = transports[sessionId];
|
||||
if (transport) {
|
||||
await transport.handlePostMessage(req, res);
|
||||
} else {
|
||||
console.error(`No transport found for sessionId: ${sessionId}`);
|
||||
res.status(400).send('No transport found for sessionId');
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server is running on port ${PORT}`);
|
||||
});
|
||||
|
||||
export default app;
|
||||
export default appServer.getApp();
|
||||
|
||||
33
src/middlewares/index.ts
Normal file
33
src/middlewares/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import path from 'path';
|
||||
|
||||
export const errorHandler = (
|
||||
err: Error,
|
||||
_req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction
|
||||
): void => {
|
||||
console.error('Unhandled error:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error'
|
||||
});
|
||||
};
|
||||
|
||||
export const initMiddlewares = (app: express.Application): void => {
|
||||
app.use(express.static('public'));
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (req.path !== '/sse' && req.path !== '/messages') {
|
||||
express.json()(req, res, next);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/', (_req: Request, res: Response) => {
|
||||
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
|
||||
});
|
||||
|
||||
app.use(errorHandler);
|
||||
};
|
||||
24
src/routes/index.ts
Normal file
24
src/routes/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import express from 'express';
|
||||
import {
|
||||
getAllServers,
|
||||
getAllSettings,
|
||||
createServer,
|
||||
deleteServer,
|
||||
setMcpServerInstance
|
||||
} from '../controllers/serverController.js';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
export const initRoutes = (app: express.Application, server: McpServer): void => {
|
||||
setMcpServerInstance(server);
|
||||
|
||||
router.get('/servers', getAllServers);
|
||||
router.get('/settings', getAllSettings);
|
||||
router.post('/servers', createServer);
|
||||
router.delete('/servers/:name', deleteServer);
|
||||
|
||||
app.use('/api', router);
|
||||
};
|
||||
|
||||
export default router;
|
||||
319
src/server.ts
319
src/server.ts
@@ -1,300 +1,49 @@
|
||||
import express from 'express';
|
||||
import config from './config/index.js';
|
||||
import { createMcpServer, registerAllTools } from './services/mcpService.js';
|
||||
import { initMiddlewares } from './middlewares/index.js';
|
||||
import { initRoutes } from './routes/index.js';
|
||||
import { handleSseConnection, handleSseMessage } from './services/sseService.js';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import * as z from 'zod';
|
||||
import { ZodType, ZodRawShape } from 'zod';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Define settings interface
|
||||
interface McpSettings {
|
||||
mcpServers: {
|
||||
[key: string]: {
|
||||
url?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
}
|
||||
export class AppServer {
|
||||
private app: express.Application;
|
||||
private mcpServer: McpServer;
|
||||
private port: number | string;
|
||||
|
||||
// Add type definitions for API responses
|
||||
export interface ServerInfo {
|
||||
name: string;
|
||||
status: 'connected' | 'connecting' | 'disconnected';
|
||||
tools: ToolInfo[];
|
||||
client?: Client;
|
||||
transport?: SSEClientTransport | StdioClientTransport;
|
||||
}
|
||||
|
||||
export interface ToolInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Function to read and parse the settings file
|
||||
function loadSettings(): McpSettings {
|
||||
const settingsPath = path.resolve(process.cwd(), 'mcp_settings.json');
|
||||
try {
|
||||
const settingsData = fs.readFileSync(settingsPath, 'utf8');
|
||||
return JSON.parse(settingsData);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load settings from ${settingsPath}:`, error);
|
||||
return { mcpServers: {} };
|
||||
}
|
||||
}
|
||||
|
||||
// Function to save settings to file
|
||||
export function saveSettings(settings: McpSettings): boolean {
|
||||
const settingsPath = path.resolve(process.cwd(), 'mcp_settings.json');
|
||||
try {
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to save settings to ${settingsPath}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize clients and transports from settings
|
||||
function initializeClientsFromSettings(): ServerInfo[] {
|
||||
const settings = loadSettings();
|
||||
|
||||
function expandEnvVars(value: string) {
|
||||
return value.replace(/\$\{([^}]+)\}/g, (_, key) => process.env[key] || '');
|
||||
constructor() {
|
||||
this.app = express();
|
||||
this.port = config.port;
|
||||
this.mcpServer = createMcpServer(config.mcpHubName, config.mcpHubVersion);
|
||||
}
|
||||
|
||||
for (const [name, config] of Object.entries(settings.mcpServers)) {
|
||||
const serverInfo = serverInfos.find((info) => info.name === name);
|
||||
if (serverInfo && serverInfo.status === 'connected') {
|
||||
console.log(`Server '${name}' is already connected.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let transport;
|
||||
|
||||
if (config.url) {
|
||||
transport = new SSEClientTransport(new URL(config.url));
|
||||
} else if (config.command && config.args) {
|
||||
const rawEnv = { ...process.env, ...(config.env || {}) };
|
||||
const env: Record<string, string> = {};
|
||||
for (const key in rawEnv) {
|
||||
if (typeof rawEnv[key] === 'string') {
|
||||
env[key] = expandEnvVars(rawEnv[key] as string);
|
||||
}
|
||||
}
|
||||
|
||||
transport = new StdioClientTransport({
|
||||
command: config.command,
|
||||
args: config.args,
|
||||
env: env,
|
||||
});
|
||||
} else {
|
||||
console.warn(`Skipping server '${name}': missing required configuration`);
|
||||
serverInfos.push({
|
||||
name,
|
||||
status: 'disconnected',
|
||||
tools: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: `mcp-client-${name}`,
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
resources: {},
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
serverInfos.push({
|
||||
name,
|
||||
status: 'connecting', // Set to connecting when client exists
|
||||
tools: [],
|
||||
client,
|
||||
transport,
|
||||
});
|
||||
|
||||
console.log(`Initialized client for server: ${name}`);
|
||||
}
|
||||
|
||||
return serverInfos;
|
||||
}
|
||||
|
||||
// Initialize server info
|
||||
let serverInfos: ServerInfo[] = [];
|
||||
serverInfos = initializeClientsFromSettings();
|
||||
|
||||
// Export the registerAllTools function
|
||||
export const registerAllTools = async (server: McpServer) => {
|
||||
for (const serverInfo of serverInfos) {
|
||||
if (serverInfo.status === 'connected') continue;
|
||||
if (!serverInfo.client || !serverInfo.transport) continue;
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
serverInfo.status = 'connecting';
|
||||
console.log(`Connecting to server: ${serverInfo.name}...`);
|
||||
|
||||
await serverInfo.client.connect(serverInfo.transport);
|
||||
const tools = await serverInfo.client.listTools();
|
||||
|
||||
serverInfo.tools = tools.tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description || '',
|
||||
inputSchema: tool.inputSchema.properties || {},
|
||||
}));
|
||||
|
||||
serverInfo.status = 'connected';
|
||||
console.log(`Successfully connected to server: ${serverInfo.name}`);
|
||||
|
||||
for (const tool of tools.tools) {
|
||||
console.log(`Registering tool: ${JSON.stringify(tool)}`);
|
||||
await server.tool(
|
||||
tool.name,
|
||||
tool.description || '',
|
||||
cast(tool.inputSchema.properties),
|
||||
async (params: Record<string, unknown>) => {
|
||||
console.log(`Calling tool: ${tool.name} with params: ${JSON.stringify(params)}`);
|
||||
const result = await serverInfo.client!.callTool({
|
||||
name: tool.name,
|
||||
arguments: params,
|
||||
});
|
||||
console.log(`Tool result: ${JSON.stringify(result)}`);
|
||||
return result as CallToolResult;
|
||||
},
|
||||
);
|
||||
}
|
||||
await registerAllTools(this.mcpServer);
|
||||
initMiddlewares(this.app);
|
||||
initRoutes(this.app, this.mcpServer);
|
||||
this.app.get('/sse', (req, res) => handleSseConnection(req, res, this.mcpServer));
|
||||
this.app.post('/messages', handleSseMessage);
|
||||
console.log('Server initialized successfully');
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to connect to server for client: ${serverInfo.name} by error: ${error}`,
|
||||
);
|
||||
serverInfo.status = 'disconnected';
|
||||
console.error('Error initializing server:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add function to get current server status
|
||||
export function getServersInfo(): ServerInfo[] {
|
||||
return serverInfos.map(({ name, status, tools }) => ({
|
||||
name,
|
||||
status,
|
||||
tools,
|
||||
}));
|
||||
}
|
||||
start(): void {
|
||||
this.app.listen(this.port, () => {
|
||||
console.log(`Server is running on port ${this.port}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Add function to get all server settings
|
||||
export function getServersSettings(): McpSettings {
|
||||
return loadSettings();
|
||||
}
|
||||
getApp(): express.Application {
|
||||
return this.app;
|
||||
}
|
||||
|
||||
// Add function to add a new server
|
||||
export async function addServer(
|
||||
mcpServer: McpServer,
|
||||
name: string,
|
||||
config: { url?: string; command?: string; args?: string[]; env?: Record<string, string> },
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (settings.mcpServers[name]) {
|
||||
return { success: false, message: 'Server name already exists' };
|
||||
}
|
||||
|
||||
settings.mcpServers[name] = config;
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return { success: false, message: 'Failed to save settings' };
|
||||
}
|
||||
|
||||
serverInfos = initializeClientsFromSettings();
|
||||
registerAllTools(mcpServer);
|
||||
|
||||
return { success: true, message: 'Server added successfully' };
|
||||
} catch (error) {
|
||||
console.error(`Failed to add server: ${name}`, error);
|
||||
return { success: false, message: 'Failed to add server' };
|
||||
getMcpServer(): McpServer {
|
||||
return this.mcpServer;
|
||||
}
|
||||
}
|
||||
|
||||
export function removeServer(name: string): {
|
||||
success: boolean;
|
||||
} {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
|
||||
if (!settings.mcpServers[name]) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
delete settings.mcpServers[name];
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
|
||||
if (serverInfo && serverInfo.client) {
|
||||
serverInfo.client.close();
|
||||
serverInfo.transport?.close();
|
||||
}
|
||||
serverInfos = serverInfos.filter((serverInfo) => serverInfo.name !== name);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error(`Failed to remove server: ${name}`, error);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
function cast(inputSchema: unknown): ZodRawShape {
|
||||
if (typeof inputSchema !== 'object' || inputSchema === null) {
|
||||
throw new Error('Invalid input schema');
|
||||
}
|
||||
|
||||
const properties = inputSchema as Record<string, { type: string; description?: string }>;
|
||||
const processedSchema: ZodRawShape = {};
|
||||
for (const key in properties) {
|
||||
const prop = properties[key];
|
||||
if (prop instanceof ZodType) {
|
||||
processedSchema[key] = prop.optional();
|
||||
} else if (typeof prop === 'object' && prop !== null) {
|
||||
let zodType: ZodType;
|
||||
switch (prop.type) {
|
||||
case 'string':
|
||||
zodType = z.string();
|
||||
break;
|
||||
case 'number':
|
||||
zodType = z.number();
|
||||
break;
|
||||
case 'boolean':
|
||||
zodType = z.boolean();
|
||||
break;
|
||||
case 'integer':
|
||||
zodType = z.number().int();
|
||||
break;
|
||||
case 'array':
|
||||
zodType = z.array(z.any());
|
||||
break;
|
||||
case 'object':
|
||||
zodType = z.record(z.any());
|
||||
break;
|
||||
default:
|
||||
zodType = z.any();
|
||||
}
|
||||
|
||||
if (prop.description) {
|
||||
zodType = zodType.describe(prop.description);
|
||||
}
|
||||
|
||||
processedSchema[key] = zodType.optional();
|
||||
}
|
||||
}
|
||||
return processedSchema;
|
||||
}
|
||||
export default AppServer;
|
||||
|
||||
258
src/services/mcpService.ts
Normal file
258
src/services/mcpService.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
||||
import * as z from 'zod';
|
||||
import { ZodType, ZodRawShape } from 'zod';
|
||||
import { ServerInfo, ServerConfig, ToolInfo } from '../types/index.js';
|
||||
import { loadSettings, saveSettings, expandEnvVars } from '../config/index.js';
|
||||
|
||||
// Store all server information
|
||||
let serverInfos: ServerInfo[] = [];
|
||||
|
||||
// Initialize MCP server clients
|
||||
export const initializeClientsFromSettings = (): ServerInfo[] => {
|
||||
const settings = loadSettings();
|
||||
const existingServerInfos = serverInfos;
|
||||
serverInfos = [];
|
||||
|
||||
for (const [name, config] of Object.entries(settings.mcpServers)) {
|
||||
// Check if server is already connected
|
||||
const existingServer = existingServerInfos.find(s => s.name === name && s.status === 'connected');
|
||||
if (existingServer) {
|
||||
serverInfos.push(existingServer);
|
||||
console.log(`Server '${name}' is already connected.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let transport;
|
||||
if (config.url) {
|
||||
transport = new SSEClientTransport(new URL(config.url));
|
||||
} else if (config.command && config.args) {
|
||||
const rawEnv = { ...process.env, ...(config.env || {}) };
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
for (const key in rawEnv) {
|
||||
if (typeof rawEnv[key] === 'string') {
|
||||
env[key] = expandEnvVars(rawEnv[key] as string);
|
||||
}
|
||||
}
|
||||
|
||||
transport = new StdioClientTransport({
|
||||
command: config.command,
|
||||
args: config.args,
|
||||
env: env,
|
||||
});
|
||||
} else {
|
||||
console.warn(`Skipping server '${name}': missing required configuration`);
|
||||
serverInfos.push({
|
||||
name,
|
||||
status: 'disconnected',
|
||||
tools: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: `mcp-client-${name}`,
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
resources: {},
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
serverInfos.push({
|
||||
name,
|
||||
status: 'connecting',
|
||||
tools: [],
|
||||
client,
|
||||
transport,
|
||||
});
|
||||
console.log(`Initialized client for server: ${name}`);
|
||||
}
|
||||
|
||||
return serverInfos;
|
||||
};
|
||||
|
||||
// Register all MCP tools
|
||||
export const registerAllTools = async (server: McpServer): Promise<void> => {
|
||||
for (const serverInfo of serverInfos) {
|
||||
if (serverInfo.status === 'connected') continue;
|
||||
if (!serverInfo.client || !serverInfo.transport) continue;
|
||||
|
||||
try {
|
||||
serverInfo.status = 'connecting';
|
||||
console.log(`Connecting to server: ${serverInfo.name}...`);
|
||||
|
||||
await serverInfo.client.connect(serverInfo.transport);
|
||||
const tools = await serverInfo.client.listTools();
|
||||
|
||||
serverInfo.tools = tools.tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description || '',
|
||||
inputSchema: tool.inputSchema.properties || {},
|
||||
}));
|
||||
|
||||
serverInfo.status = 'connected';
|
||||
console.log(`Successfully connected to server: ${serverInfo.name}`);
|
||||
|
||||
for (const tool of tools.tools) {
|
||||
console.log(`Registering tool: ${JSON.stringify(tool)}`);
|
||||
|
||||
await server.tool(
|
||||
tool.name,
|
||||
tool.description || '',
|
||||
cast(tool.inputSchema.properties),
|
||||
async (params: Record<string, unknown>) => {
|
||||
console.log(`Calling tool: ${tool.name} with params: ${JSON.stringify(params)}`);
|
||||
|
||||
const result = await serverInfo.client!.callTool({
|
||||
name: tool.name,
|
||||
arguments: params,
|
||||
});
|
||||
|
||||
console.log(`Tool result: ${JSON.stringify(result)}`);
|
||||
return result as CallToolResult;
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to connect to server for client: ${serverInfo.name} by error: ${error}`);
|
||||
serverInfo.status = 'disconnected';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Get all server information
|
||||
export const getServersInfo = (): Omit<ServerInfo, 'client' | 'transport'>[] => {
|
||||
return serverInfos.map(({ name, status, tools }) => ({
|
||||
name,
|
||||
status,
|
||||
tools,
|
||||
}));
|
||||
};
|
||||
|
||||
// Add new server
|
||||
export const addServer = async (
|
||||
mcpServer: McpServer,
|
||||
name: string,
|
||||
config: ServerConfig
|
||||
): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
|
||||
if (settings.mcpServers[name]) {
|
||||
return { success: false, message: 'Server name already exists' };
|
||||
}
|
||||
|
||||
settings.mcpServers[name] = config;
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return { success: false, message: 'Failed to save settings' };
|
||||
}
|
||||
|
||||
// Reinitialize clients and register tools
|
||||
serverInfos = initializeClientsFromSettings();
|
||||
await registerAllTools(mcpServer);
|
||||
|
||||
return { success: true, message: 'Server added successfully' };
|
||||
} catch (error) {
|
||||
console.error(`Failed to add server: ${name}`, error);
|
||||
return { success: false, message: 'Failed to add server' };
|
||||
}
|
||||
};
|
||||
|
||||
// Remove server
|
||||
export const removeServer = (name: string): { success: boolean; message?: string } => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
|
||||
if (!settings.mcpServers[name]) {
|
||||
return { success: false, message: 'Server not found' };
|
||||
}
|
||||
|
||||
delete settings.mcpServers[name];
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return { success: false, message: 'Failed to save settings' };
|
||||
}
|
||||
|
||||
// Close existing connections
|
||||
const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
|
||||
if (serverInfo && serverInfo.client) {
|
||||
serverInfo.client.close();
|
||||
serverInfo.transport?.close();
|
||||
}
|
||||
|
||||
// Remove from list
|
||||
serverInfos = serverInfos.filter((serverInfo) => serverInfo.name !== name);
|
||||
|
||||
return { success: true, message: 'Server removed successfully' };
|
||||
} catch (error) {
|
||||
console.error(`Failed to remove server: ${name}`, error);
|
||||
return { success: false, message: `Failed to remove server: ${error}` };
|
||||
}
|
||||
};
|
||||
|
||||
// Create McpServer instance
|
||||
export const createMcpServer = (name: string, version: string): McpServer => {
|
||||
return new McpServer({ name, version });
|
||||
};
|
||||
|
||||
// Helper function: Convert JSON Schema to Zod Schema
|
||||
function cast(inputSchema: unknown): ZodRawShape {
|
||||
if (typeof inputSchema !== 'object' || inputSchema === null) {
|
||||
throw new Error('Invalid input schema');
|
||||
}
|
||||
|
||||
const properties = inputSchema as Record<string, { type: string; description?: string }>;
|
||||
const processedSchema: ZodRawShape = {};
|
||||
|
||||
for (const key in properties) {
|
||||
const prop = properties[key];
|
||||
|
||||
if (prop instanceof ZodType) {
|
||||
processedSchema[key] = prop.optional();
|
||||
} else if (typeof prop === 'object' && prop !== null) {
|
||||
let zodType: ZodType;
|
||||
|
||||
switch (prop.type) {
|
||||
case 'string':
|
||||
zodType = z.string();
|
||||
break;
|
||||
case 'number':
|
||||
zodType = z.number();
|
||||
break;
|
||||
case 'boolean':
|
||||
zodType = z.boolean();
|
||||
break;
|
||||
case 'integer':
|
||||
zodType = z.number().int();
|
||||
break;
|
||||
case 'array':
|
||||
zodType = z.array(z.any());
|
||||
break;
|
||||
case 'object':
|
||||
zodType = z.record(z.any());
|
||||
break;
|
||||
default:
|
||||
zodType = z.any();
|
||||
}
|
||||
|
||||
if (prop.description) {
|
||||
zodType = zodType.describe(prop.description);
|
||||
}
|
||||
|
||||
processedSchema[key] = zodType.optional();
|
||||
}
|
||||
}
|
||||
|
||||
return processedSchema;
|
||||
}
|
||||
41
src/services/sseService.ts
Normal file
41
src/services/sseService.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
|
||||
const transports: { [sessionId: string]: SSEServerTransport } = {};
|
||||
|
||||
export const handleSseConnection = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
server: McpServer
|
||||
): Promise<void> => {
|
||||
const transport = new SSEServerTransport('/messages', res);
|
||||
transports[transport.sessionId] = transport;
|
||||
|
||||
res.on('close', () => {
|
||||
delete transports[transport.sessionId];
|
||||
console.log(`SSE connection closed: ${transport.sessionId}`);
|
||||
});
|
||||
|
||||
console.log(`New SSE connection established: ${transport.sessionId}`);
|
||||
await server.connect(transport);
|
||||
};
|
||||
|
||||
export const handleSseMessage = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const transport = transports[sessionId];
|
||||
|
||||
if (transport) {
|
||||
await transport.handlePostMessage(req, res);
|
||||
} else {
|
||||
console.error(`No transport found for sessionId: ${sessionId}`);
|
||||
res.status(400).send('No transport found for sessionId');
|
||||
}
|
||||
};
|
||||
|
||||
export const getConnectionCount = (): number => {
|
||||
return Object.keys(transports).length;
|
||||
};
|
||||
41
src/types/index.ts
Normal file
41
src/types/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
|
||||
export interface McpSettings {
|
||||
mcpServers: {
|
||||
[key: string]: ServerConfig;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
url?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ServerInfo {
|
||||
name: string;
|
||||
status: 'connected' | 'connecting' | 'disconnected';
|
||||
tools: ToolInfo[];
|
||||
client?: Client;
|
||||
transport?: SSEClientTransport | StdioClientTransport;
|
||||
}
|
||||
|
||||
export interface ToolInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
export interface AddServerRequest {
|
||||
name: string;
|
||||
config: ServerConfig;
|
||||
}
|
||||
Reference in New Issue
Block a user