349 lines
13 KiB
JavaScript
349 lines
13 KiB
JavaScript
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
import { z } from 'zod';
|
|
import { detectServerType } from './config.js';
|
|
import { getVersion } from './getVersion.js';
|
|
export class McpServerManager {
|
|
servers = new Map();
|
|
logger;
|
|
constructor(logger) {
|
|
this.logger = logger;
|
|
}
|
|
async addServer(name, config) {
|
|
const serverType = detectServerType(config);
|
|
const client = new Client({
|
|
name: 'mcp-superassistant-proxy',
|
|
version: getVersion(),
|
|
}, {
|
|
capabilities: {},
|
|
});
|
|
let transport;
|
|
let child;
|
|
if (serverType === 'stdio') {
|
|
if (!config.command) {
|
|
throw new Error(`Stdio server ${name} missing command`);
|
|
}
|
|
const args = config.args || [];
|
|
this.logger.info(`Starting server ${name}: ${config.command} ${args.join(' ')}`);
|
|
this.logger.debug(`Command: "${config.command}", Args: [${args.map((a) => `"${a}"`).join(', ')}]`);
|
|
this.logger.debug(`Creating StdioClientTransport for ${name}`);
|
|
try {
|
|
// Use command and args to create the transport, similar to test files
|
|
// StdioClientTransport will handle spawning the process internally
|
|
transport = new StdioClientTransport({
|
|
command: config.command,
|
|
args: args,
|
|
env: config.env ? { ...process.env, ...config.env } : process.env,
|
|
});
|
|
this.logger.debug(`StdioClientTransport created for ${name}`);
|
|
}
|
|
catch (transportErr) {
|
|
this.logger.error(`Failed to create StdioClientTransport for ${name}:`, transportErr);
|
|
throw transportErr;
|
|
}
|
|
}
|
|
else if (serverType === 'sse') {
|
|
if (!config.url) {
|
|
throw new Error(`HTTP server ${name} missing URL`);
|
|
}
|
|
const url = new URL(config.url);
|
|
if (url.pathname.endsWith('/sse') || url.pathname.includes('/sse')) {
|
|
const headers = config.headers || {};
|
|
this.logger.info(`Connecting to SSE server ${name} with headers: ${Object.keys(headers).length ? JSON.stringify(headers) : '(none)'}`);
|
|
transport = new SSEClientTransport(url, {
|
|
eventSourceInit: {
|
|
fetch: (...props) => {
|
|
const [url, init = {}] = props;
|
|
return fetch(url, {
|
|
...init,
|
|
headers: { ...init.headers, ...headers }
|
|
});
|
|
},
|
|
},
|
|
requestInit: {
|
|
headers,
|
|
},
|
|
});
|
|
}
|
|
else {
|
|
throw new Error(`HTTP server ${name} URL must be an SSE endpoint (path should end with /sse)`);
|
|
}
|
|
}
|
|
else if (serverType === 'streamable-http') {
|
|
if (!config.url) {
|
|
throw new Error(`Streamable HTTP server ${name} missing URL`);
|
|
}
|
|
const headers = config.headers || {};
|
|
this.logger.info(`Connecting to streamable HTTP server ${name}: ${config.url}`);
|
|
this.logger.info(`With headers: ${Object.keys(headers).length ? JSON.stringify(headers) : '(none)'}`);
|
|
const url = new URL(config.url);
|
|
transport = new StreamableHTTPClientTransport(url, {
|
|
requestInit: {
|
|
headers,
|
|
},
|
|
});
|
|
}
|
|
else {
|
|
throw new Error(`Unsupported server type: ${serverType}`);
|
|
}
|
|
try {
|
|
this.logger.debug(`Attempting to connect client to transport for ${name}`);
|
|
await client.connect(transport);
|
|
this.logger.info(`Connected to server: ${name}`);
|
|
const server = {
|
|
name,
|
|
config,
|
|
client,
|
|
tools: [],
|
|
resources: [],
|
|
connected: true,
|
|
child: child || undefined,
|
|
};
|
|
try {
|
|
const toolsResponse = (await client.request({ method: 'tools/list', params: {} }, z.object({ tools: z.array(z.any()) })));
|
|
server.tools = toolsResponse.tools || [];
|
|
this.logger.info(`Server ${name} has ${server.tools.length} tools`);
|
|
}
|
|
catch (err) {
|
|
this.logger.warn(`Server ${name} does not support tools: ${err}`);
|
|
}
|
|
try {
|
|
const resourcesResponse = (await client.request({ method: 'resources/list', params: {} }, z.object({ resources: z.array(z.any()) })));
|
|
server.resources = resourcesResponse.resources || [];
|
|
this.logger.info(`Server ${name} has ${server.resources.length} resources`);
|
|
}
|
|
catch (err) {
|
|
this.logger.debug(`Server ${name} does not support resources: ${err}`);
|
|
}
|
|
this.servers.set(name, server);
|
|
}
|
|
catch (err) {
|
|
if (child) {
|
|
child.kill();
|
|
}
|
|
throw new Error(`Failed to connect to server ${name}: ${err}`);
|
|
}
|
|
}
|
|
async handleRequest(request) {
|
|
const { method, params, id } = request;
|
|
if (method === 'initialize') {
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id,
|
|
result: {
|
|
protocolVersion: '2024-11-05',
|
|
capabilities: {
|
|
tools: {},
|
|
resources: {},
|
|
},
|
|
serverInfo: {
|
|
name: 'mcp-superassistant-proxy-unified',
|
|
version: getVersion(),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
if (method === 'tools/list') {
|
|
const allTools = [];
|
|
for (const [serverName, server] of this.servers) {
|
|
if (server.connected) {
|
|
for (const tool of server.tools) {
|
|
allTools.push({
|
|
...tool,
|
|
name: `${serverName}.${tool.name}`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id,
|
|
result: { tools: allTools },
|
|
};
|
|
}
|
|
if (method === 'resources/list') {
|
|
const allResources = [];
|
|
for (const [serverName, server] of this.servers) {
|
|
if (server.connected) {
|
|
for (const resource of server.resources) {
|
|
allResources.push({
|
|
...resource,
|
|
name: `${serverName}.${resource.name}`,
|
|
uri: `${serverName}://${resource.uri}`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id,
|
|
result: { resources: allResources },
|
|
};
|
|
}
|
|
if (method === 'tools/call') {
|
|
const toolName = params?.name;
|
|
if (!toolName) {
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id,
|
|
error: {
|
|
code: -32602,
|
|
message: 'Tool name is required',
|
|
},
|
|
};
|
|
}
|
|
let serverName;
|
|
let originalToolName;
|
|
if (toolName.includes('.')) {
|
|
// Tool name includes server prefix
|
|
;
|
|
[serverName, originalToolName] = toolName.split('.', 2);
|
|
}
|
|
else {
|
|
// No server prefix, find which server has this tool
|
|
let foundServer = null;
|
|
for (const [sName, server] of this.servers) {
|
|
if (server.connected &&
|
|
server.tools.some((tool) => tool.name === toolName)) {
|
|
if (foundServer) {
|
|
// Tool exists in multiple servers, require explicit server name
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id,
|
|
error: {
|
|
code: -32602,
|
|
message: `Tool '${toolName}' exists in multiple servers (${foundServer}, ${sName}). Use format: servername.toolname`,
|
|
},
|
|
};
|
|
}
|
|
foundServer = sName;
|
|
}
|
|
}
|
|
if (!foundServer) {
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id,
|
|
error: {
|
|
code: -32601,
|
|
message: `Tool '${toolName}' not found in any connected server`,
|
|
},
|
|
};
|
|
}
|
|
serverName = foundServer;
|
|
originalToolName = toolName;
|
|
}
|
|
const server = this.servers.get(serverName);
|
|
if (!server || !server.connected) {
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id,
|
|
error: {
|
|
code: -32601,
|
|
message: `Server ${serverName} not found or not connected`,
|
|
},
|
|
};
|
|
}
|
|
try {
|
|
const response = await server.client.request({
|
|
method: 'tools/call',
|
|
params: {
|
|
...params,
|
|
name: originalToolName,
|
|
},
|
|
}, z.any());
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id,
|
|
result: response,
|
|
};
|
|
}
|
|
catch (err) {
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id,
|
|
error: {
|
|
code: err.code || -32000,
|
|
message: err.message || 'Tool call failed',
|
|
},
|
|
};
|
|
}
|
|
}
|
|
if (method === 'resources/read') {
|
|
const uri = params?.uri;
|
|
if (!uri || !uri.includes('://')) {
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id,
|
|
error: {
|
|
code: -32602,
|
|
message: 'Invalid resource URI. Expected format: servername://resource-uri',
|
|
},
|
|
};
|
|
}
|
|
const [serverName, originalUri] = uri.split('://', 2);
|
|
const server = this.servers.get(serverName);
|
|
if (!server || !server.connected) {
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id,
|
|
error: {
|
|
code: -32601,
|
|
message: `Server ${serverName} not found or not connected`,
|
|
},
|
|
};
|
|
}
|
|
try {
|
|
const response = await server.client.request({
|
|
method: 'resources/read',
|
|
params: {
|
|
...params,
|
|
uri: originalUri,
|
|
},
|
|
}, z.any());
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id,
|
|
result: response,
|
|
};
|
|
}
|
|
catch (err) {
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id,
|
|
error: {
|
|
code: err.code || -32000,
|
|
message: err.message || 'Resource read failed',
|
|
},
|
|
};
|
|
}
|
|
}
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id,
|
|
error: {
|
|
code: -32601,
|
|
message: `Method not found: ${method}`,
|
|
},
|
|
};
|
|
}
|
|
getServers() {
|
|
return this.servers;
|
|
}
|
|
async cleanup() {
|
|
for (const [name, server] of this.servers) {
|
|
try {
|
|
if (server.child) {
|
|
server.child.kill();
|
|
}
|
|
this.logger.info(`Cleaned up server: ${name}`);
|
|
}
|
|
catch (err) {
|
|
this.logger.error(`Error cleaning up server ${name}: ${err}`);
|
|
}
|
|
}
|
|
this.servers.clear();
|
|
}
|
|
}
|
|
//# sourceMappingURL=mcpServerManager.js.map
|