diff --git a/client/bin/start.js b/client/bin/start.js index ad75078a..8e37e8ea 100755 --- a/client/bin/start.js +++ b/client/bin/start.js @@ -5,6 +5,8 @@ import { resolve, dirname } from "path"; import { spawnPromise, spawn } from "spawn-rx"; import { fileURLToPath } from "url"; import { randomBytes } from "crypto"; +import fs from "fs"; +import path from "path"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -171,6 +173,16 @@ async function main() { let command = null; let parsingFlags = true; let isDev = false; + let configPath = null; + let serverName = null; + + // Inspector can either be ran with a config file or a command to start an MCP server + // Order of precedence is: + // 1. Load configuration from MCP server config file + // 2. Use direct command (and args) provided on the command line (if no config file is provided) + + // Early check if an MCP server config file is provided to make logic simpler below + const configProvided = args.includes("--config"); for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -180,29 +192,96 @@ async function main() { continue; } - if (parsingFlags && arg === "--dev") { - isDev = true; - continue; - } + if (parsingFlags) { + // Parse the --dev flag to run the inspector in development mode + // This will ignore any command or args provided on the command line + if (parsingFlags && arg === "--dev") { + isDev = true; + continue; + } + + // Parse a file path to an MCP servers' config file where each server has: + // - Server type (sse, streamable-http, or stdio) + // - Server URL (for sse/streamable-http) + // - Command and args (for stdio) + // - Environment variables + if (arg === "--config" && i + 1 < args.length) { + configPath = args[++i]; + continue; + } + + // Parse a server name to use from the relevant config file + if (arg === "--server" && i + 1 < args.length) { + serverName = args[++i]; + continue; + } - if (parsingFlags && arg === "-e" && i + 1 < args.length) { - const envVar = args[++i]; - const equalsIndex = envVar.indexOf("="); + // Process any environment variables (in addition to those provided in the config file) + // CLI env vars will override those in the config file - handled below + // Format: -e KEY=VALUE or -e KEY (empty value) + if (arg === "-e" && i + 1 < args.length) { + const envVar = args[++i]; + const equalsIndex = envVar.indexOf("="); + + if (equalsIndex !== -1) { + const key = envVar.substring(0, equalsIndex); + const value = envVar.substring(equalsIndex + 1); + envVars[key] = value; + } else { + envVars[envVar] = ""; + } + continue; + } + } - if (equalsIndex !== -1) { - const key = envVar.substring(0, equalsIndex); - const value = envVar.substring(equalsIndex + 1); - envVars[key] = value; + // If a config file isn't provided, then an explicit command (and args) can be provided instead + // eg. node //some/path/to/a/build/index.js + if (!configProvided) { + // Set the first argument as the command to run + if (!command) { + command = arg; } else { - envVars[envVar] = ""; + // If a command has already been provided, then the remaining args as passed to the command + mcpServerArgs.push(arg); } - } else if (!command && !isDev) { - command = arg; - } else if (!isDev) { - mcpServerArgs.push(arg); } } + if ((configPath && !serverName) || (!configPath && serverName)) { + console.error("Both --config and --server must be provided together."); + process.exit(1); + } + + let serverConfig = null; + if (configPath && serverName) { + try { + serverConfig = loadConfigFile(configPath, serverName); + console.log( + `Loaded configuration for '${serverName}' from '${configPath}'`, + ); + } catch (error) { + console.error(`Error loading config: ${error.message}`); + process.exit(1); + } + } + + const inspectorServerPath = resolve( + __dirname, + "../..", + "server", + "build", + "index.js", + ); + + // Path to the client entry point + const inspectorClientPath = resolve( + __dirname, + "../..", + "client", + "bin", + "client.js", + ); + const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274"; const SERVER_PORT = process.env.SERVER_PORT ?? "6277"; @@ -224,6 +303,61 @@ async function main() { abort.abort(); }); + // Build server arguments based on config or command line + let serverArgs = []; + + // Environment variables precedence: + // 1. Command line env vars (-e flag) take highest precedence + // 2. Config file env vars are next + // 3. System environment variables are lowest precedence + let envVarsToPass = { ...envVars }; + + let serverEnv = { + ...process.env, + PORT: SERVER_PORT, + }; + + if (serverConfig) { + if ( + serverConfig.type === "sse" || + serverConfig.type === "streamable-http" + ) { + console.log( + `Using ${serverConfig.type} transport with URL: ${serverConfig.url}`, + ); + serverEnv.MCP_SERVER_CONFIG = JSON.stringify(serverConfig); + } else if (serverConfig.command) { + console.log( + `Using stdio transport with command: ${serverConfig.command}`, + ); + serverArgs = [ + ...(serverConfig.command ? [`--env`, serverConfig.command] : []), + ...(serverConfig.args ? [`--args=${serverConfig.args.join(" ")}`] : []), + ]; + } + + // Treat command line env vars as overrides of server config + envVarsToPass = { + ...(serverConfig.env ?? {}), + ...envVarsToPass, + }; + } else { + serverArgs = [ + ...(command ? [`--env`, command] : []), + ...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []), + ]; + } + + serverEnv.MCP_ENV_VARS = JSON.stringify(envVarsToPass); + + let server, serverOk; + try { + server = spawnPromise("node", [inspectorServerPath, ...serverArgs], { + env: serverEnv, + signal: abort.signal, + echoOutput: true, + }); + let server, serverOk; try { @@ -266,6 +400,39 @@ async function main() { return 0; } +function loadConfigFile(configPath, serverName) { + try { + const resolvedConfigPath = path.isAbsolute(configPath) + ? configPath + : path.resolve(process.cwd(), configPath); + + if (!fs.existsSync(resolvedConfigPath)) { + console.error(`Config file not found: ${resolvedConfigPath}`); + process.exit(1); + } + + const configContent = fs.readFileSync(resolvedConfigPath, "utf8"); + const parsedConfig = JSON.parse(configContent); + + if (!parsedConfig.mcpServers || !parsedConfig.mcpServers[serverName]) { + const availableServers = Object.keys(parsedConfig.mcpServers || {}).join( + ", ", + ); + console.error( + `Server '${serverName}' not found in config file. Available servers: ${availableServers}`, + ); + process.exit(1); + } + + return parsedConfig.mcpServers[serverName]; + } catch (err) { + if (err instanceof SyntaxError) { + throw new Error(`Invalid JSON in config file: ${err.message}`); + } + throw err; + } +} + main() .then((_) => process.exit(0)) .catch((e) => { diff --git a/client/src/App.tsx b/client/src/App.tsx index 26eb44c5..9b84ed56 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -363,6 +363,25 @@ const App = () => { if (data.defaultArgs) { setArgs(data.defaultArgs); } + if (data.serverConfig) { + if (data.serverConfig.type === "stdio") { + setTransportType("stdio"); + setCommand(data.serverConfig.command); + setArgs(data.serverConfig.args); + } else if ( + data.serverConfig.type === "sse" && + data.serverConfig.url + ) { + setTransportType("sse"); + setSseUrl(data.serverConfig.url); + } else if ( + data.serverConfig.type === "streamable-http" && + data.serverConfig.url + ) { + setTransportType("streamable-http"); + setSseUrl(data.serverConfig.url); + } + } }) .catch((error) => console.error("Error fetching default environment:", error), diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index 676ae87d..e9cfac7d 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -462,4 +462,67 @@ describe("useConnection", () => { ).toHaveProperty("X-MCP-Proxy-Auth", "Bearer test-proxy-token"); }); }); + + describe("Server Configuration Loading", () => { + const SSEClientTransport = jest.requireMock( + "@modelcontextprotocol/sdk/client/sse.js", + ).SSEClientTransport; + const StreamableHTTPClientTransport = jest.requireMock( + "@modelcontextprotocol/sdk/client/streamableHttp.js", + ).StreamableHTTPClientTransport; + + let originalFetch: typeof global.fetch; + + beforeEach(() => { + jest.clearAllMocks(); + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + test.each([ + { + transportType: "sse" as const, + url: "http://localhost:60157/sse", + Transport: SSEClientTransport, + }, + { + transportType: "streamable-http" as const, + url: "http://localhost:60157/http", + Transport: StreamableHTTPClientTransport, + }, + ])( + "should handle server config with $transportType transport", + async ({ transportType, url, Transport }) => { + global.fetch = jest.fn().mockResolvedValue({ + json: () => + Promise.resolve({ + status: "ok", + serverConfig: { + type: transportType, + url, + }, + }), + }); + + const props = { + ...defaultProps, + transportType, + sseUrl: url, + }; + + const { result } = renderHook(() => useConnection(props)); + + await act(async () => { + await result.current.connect(); + }); + + const call = Transport.mock.calls[0][0]; + expect(call.toString()).toContain(`url=${encodeURIComponent(url)}`); + expect(call.toString()).toContain(`transportType=${transportType}`); + }, + ); + }); }); diff --git a/server/src/index.ts b/server/src/index.ts index 7653597a..a794e67d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -33,6 +33,10 @@ const defaultEnvironment = { ...(process.env.MCP_ENV_VARS ? JSON.parse(process.env.MCP_ENV_VARS) : {}), }; +const serverConfig = process.env.MCP_SERVER_CONFIG + ? JSON.parse(process.env.MCP_SERVER_CONFIG) + : null; + const { values } = parseArgs({ args: process.argv.slice(2), options: { @@ -523,6 +527,7 @@ app.get("/config", originValidationMiddleware, authMiddleware, (req, res) => { defaultEnvironment, defaultCommand: values.env, defaultArgs: values.args, + serverConfig, }); } catch (error) { console.error("Error in /config route:", error);