Skip to content

add config parsing for SSE and streamable HTTP transports #483

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 183 additions & 16 deletions client/bin/start.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down Expand Up @@ -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];
Expand All @@ -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";

Expand All @@ -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 {
Expand Down Expand Up @@ -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) => {
Expand Down
19 changes: 19 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
63 changes: 63 additions & 0 deletions client/src/lib/hooks/__tests__/useConnection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
},
);
});
});
5 changes: 5 additions & 0 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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);
Expand Down