Merge branch 'main' into playwright-test
This commit is contained in:
commit
2ca63cb161
13 changed files with 1798 additions and 344 deletions
|
|
@ -2,8 +2,9 @@
|
|||
|
||||
import open from "open";
|
||||
import { resolve, dirname } from "path";
|
||||
import { spawnPromise } from "spawn-rx";
|
||||
import { spawnPromise, spawn } from "spawn-rx";
|
||||
import { fileURLToPath } from "url";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
|
|
@ -11,6 +12,157 @@ function delay(ms) {
|
|||
return new Promise((resolve) => setTimeout(resolve, ms, true));
|
||||
}
|
||||
|
||||
async function startDevServer(serverOptions) {
|
||||
const { SERVER_PORT, CLIENT_PORT, sessionToken, envVars, abort } =
|
||||
serverOptions;
|
||||
const serverCommand = "npx";
|
||||
const serverArgs = ["tsx", "watch", "--clear-screen=false", "src/index.ts"];
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
const spawnOptions = {
|
||||
cwd: resolve(__dirname, "../..", "server"),
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: SERVER_PORT,
|
||||
CLIENT_PORT: CLIENT_PORT,
|
||||
MCP_PROXY_TOKEN: sessionToken,
|
||||
MCP_ENV_VARS: JSON.stringify(envVars),
|
||||
},
|
||||
signal: abort.signal,
|
||||
echoOutput: true,
|
||||
};
|
||||
|
||||
// For Windows, we need to use stdin: 'ignore' to simulate < NUL
|
||||
if (isWindows) {
|
||||
spawnOptions.stdin = "ignore";
|
||||
}
|
||||
|
||||
const server = spawn(serverCommand, serverArgs, spawnOptions);
|
||||
|
||||
// Give server time to start
|
||||
const serverOk = await Promise.race([
|
||||
new Promise((resolve) => {
|
||||
server.subscribe({
|
||||
complete: () => resolve(false),
|
||||
error: () => resolve(false),
|
||||
next: () => {}, // We're using echoOutput
|
||||
});
|
||||
}),
|
||||
delay(3000).then(() => true),
|
||||
]);
|
||||
|
||||
return { server, serverOk };
|
||||
}
|
||||
|
||||
async function startProdServer(serverOptions) {
|
||||
const {
|
||||
SERVER_PORT,
|
||||
CLIENT_PORT,
|
||||
sessionToken,
|
||||
envVars,
|
||||
abort,
|
||||
command,
|
||||
mcpServerArgs,
|
||||
} = serverOptions;
|
||||
const inspectorServerPath = resolve(
|
||||
__dirname,
|
||||
"../..",
|
||||
"server",
|
||||
"build",
|
||||
"index.js",
|
||||
);
|
||||
|
||||
const server = spawnPromise(
|
||||
"node",
|
||||
[
|
||||
inspectorServerPath,
|
||||
...(command ? [`--env`, command] : []),
|
||||
...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []),
|
||||
],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: SERVER_PORT,
|
||||
CLIENT_PORT: CLIENT_PORT,
|
||||
MCP_PROXY_TOKEN: sessionToken,
|
||||
MCP_ENV_VARS: JSON.stringify(envVars),
|
||||
},
|
||||
signal: abort.signal,
|
||||
echoOutput: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Make sure server started before starting client
|
||||
const serverOk = await Promise.race([server, delay(2 * 1000)]);
|
||||
|
||||
return { server, serverOk };
|
||||
}
|
||||
|
||||
async function startDevClient(clientOptions) {
|
||||
const { CLIENT_PORT, authDisabled, sessionToken, abort, cancelled } =
|
||||
clientOptions;
|
||||
const clientCommand = "npx";
|
||||
const clientArgs = ["vite", "--port", CLIENT_PORT];
|
||||
|
||||
const client = spawn(clientCommand, clientArgs, {
|
||||
cwd: resolve(__dirname, ".."),
|
||||
env: { ...process.env, PORT: CLIENT_PORT },
|
||||
signal: abort.signal,
|
||||
echoOutput: true,
|
||||
});
|
||||
|
||||
// Auto-open browser after vite starts
|
||||
if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") {
|
||||
const url = authDisabled
|
||||
? `http://127.0.0.1:${CLIENT_PORT}`
|
||||
: `http://127.0.0.1:${CLIENT_PORT}/?MCP_PROXY_AUTH_TOKEN=${sessionToken}`;
|
||||
|
||||
// Give vite time to start before opening browser
|
||||
setTimeout(() => {
|
||||
open(url);
|
||||
console.log(`\n🔗 Opening browser at: ${url}\n`);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => {
|
||||
client.subscribe({
|
||||
complete: resolve,
|
||||
error: (err) => {
|
||||
if (!cancelled || process.env.DEBUG) {
|
||||
console.error("Client error:", err);
|
||||
}
|
||||
resolve(null);
|
||||
},
|
||||
next: () => {}, // We're using echoOutput
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startProdClient(clientOptions) {
|
||||
const { CLIENT_PORT, authDisabled, sessionToken, abort } = clientOptions;
|
||||
const inspectorClientPath = resolve(
|
||||
__dirname,
|
||||
"../..",
|
||||
"client",
|
||||
"bin",
|
||||
"client.js",
|
||||
);
|
||||
|
||||
// Auto-open browser with token
|
||||
if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") {
|
||||
const url = authDisabled
|
||||
? `http://127.0.0.1:${CLIENT_PORT}`
|
||||
: `http://127.0.0.1:${CLIENT_PORT}/?MCP_PROXY_AUTH_TOKEN=${sessionToken}`;
|
||||
open(url);
|
||||
}
|
||||
|
||||
await spawnPromise("node", [inspectorClientPath], {
|
||||
env: { ...process.env, PORT: CLIENT_PORT },
|
||||
signal: abort.signal,
|
||||
echoOutput: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
|
|
@ -18,6 +170,7 @@ async function main() {
|
|||
const mcpServerArgs = [];
|
||||
let command = null;
|
||||
let parsingFlags = true;
|
||||
let isDev = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
|
|
@ -27,6 +180,11 @@ async function main() {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (parsingFlags && arg === "--dev") {
|
||||
isDev = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsingFlags && arg === "-e" && i + 1 < args.length) {
|
||||
const envVar = args[++i];
|
||||
const equalsIndex = envVar.indexOf("=");
|
||||
|
|
@ -38,34 +196,25 @@ async function main() {
|
|||
} else {
|
||||
envVars[envVar] = "";
|
||||
}
|
||||
} else if (!command) {
|
||||
} else if (!command && !isDev) {
|
||||
command = arg;
|
||||
} else {
|
||||
} else if (!isDev) {
|
||||
mcpServerArgs.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
console.log("Starting MCP inspector...");
|
||||
console.log(
|
||||
isDev
|
||||
? "Starting MCP inspector in development mode..."
|
||||
: "Starting MCP inspector...",
|
||||
);
|
||||
|
||||
// Generate session token for authentication
|
||||
const sessionToken = randomBytes(32).toString("hex");
|
||||
const authDisabled = !!process.env.DANGEROUSLY_OMIT_AUTH;
|
||||
|
||||
const abort = new AbortController();
|
||||
|
||||
|
|
@ -74,40 +223,41 @@ async function main() {
|
|||
cancelled = true;
|
||||
abort.abort();
|
||||
});
|
||||
let server, serverOk;
|
||||
try {
|
||||
server = spawnPromise(
|
||||
"node",
|
||||
[
|
||||
inspectorServerPath,
|
||||
...(command ? [`--env`, command] : []),
|
||||
...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []),
|
||||
],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: SERVER_PORT,
|
||||
MCP_ENV_VARS: JSON.stringify(envVars),
|
||||
},
|
||||
signal: abort.signal,
|
||||
echoOutput: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Make sure server started before starting client
|
||||
serverOk = await Promise.race([server, delay(2 * 1000)]);
|
||||
let server, serverOk;
|
||||
|
||||
try {
|
||||
const serverOptions = {
|
||||
SERVER_PORT,
|
||||
CLIENT_PORT,
|
||||
sessionToken,
|
||||
envVars,
|
||||
abort,
|
||||
command,
|
||||
mcpServerArgs,
|
||||
};
|
||||
|
||||
const result = isDev
|
||||
? await startDevServer(serverOptions)
|
||||
: await startProdServer(serverOptions);
|
||||
|
||||
server = result.server;
|
||||
serverOk = result.serverOk;
|
||||
} catch (error) {}
|
||||
|
||||
if (serverOk) {
|
||||
try {
|
||||
if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") {
|
||||
open(`http://127.0.0.1:${CLIENT_PORT}`);
|
||||
}
|
||||
await spawnPromise("node", [inspectorClientPath], {
|
||||
env: { ...process.env, PORT: CLIENT_PORT },
|
||||
signal: abort.signal,
|
||||
echoOutput: true,
|
||||
});
|
||||
const clientOptions = {
|
||||
CLIENT_PORT,
|
||||
authDisabled,
|
||||
sessionToken,
|
||||
abort,
|
||||
cancelled,
|
||||
};
|
||||
|
||||
await (isDev
|
||||
? startDevClient(clientOptions)
|
||||
: startProdClient(clientOptions));
|
||||
} catch (e) {
|
||||
if (!cancelled || process.env.DEBUG) throw e;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/inspector-client",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.3",
|
||||
"description": "Client-side application for the Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
|
|
|
|||
|
|
@ -63,11 +63,13 @@ import ToolsTab from "./components/ToolsTab";
|
|||
import { InspectorConfig } from "./lib/configurationTypes";
|
||||
import {
|
||||
getMCPProxyAddress,
|
||||
getMCPProxyAuthToken,
|
||||
getInitialSseUrl,
|
||||
getInitialTransportType,
|
||||
getInitialCommand,
|
||||
getInitialArgs,
|
||||
initializeInspectorConfig,
|
||||
saveInspectorConfig,
|
||||
} from "./utils/configUtils";
|
||||
|
||||
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
|
||||
|
|
@ -226,7 +228,7 @@ const App = () => {
|
|||
}, [headerName]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config));
|
||||
saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config);
|
||||
}, [config]);
|
||||
|
||||
// Auto-connect to previously saved serverURL after OAuth callback
|
||||
|
|
@ -344,7 +346,14 @@ const App = () => {
|
|||
}, [sseUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${getMCPProxyAddress(config)}/config`)
|
||||
const headers: HeadersInit = {};
|
||||
const { token: proxyAuthToken, header: proxyAuthTokenHeader } =
|
||||
getMCPProxyAuthToken(config);
|
||||
if (proxyAuthToken) {
|
||||
headers[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`;
|
||||
}
|
||||
|
||||
fetch(`${getMCPProxyAddress(config)}/config`, { headers })
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
setEnv(data.defaultEnvironment);
|
||||
|
|
@ -358,8 +367,7 @@ const App = () => {
|
|||
.catch((error) =>
|
||||
console.error("Error fetching default environment:", error),
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
rootsRef.current = roots;
|
||||
|
|
|
|||
240
client/src/__tests__/App.config.test.tsx
Normal file
240
client/src/__tests__/App.config.test.tsx
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import { render, waitFor } from "@testing-library/react";
|
||||
import App from "../App";
|
||||
import { DEFAULT_INSPECTOR_CONFIG } from "../lib/constants";
|
||||
import { InspectorConfig } from "../lib/configurationTypes";
|
||||
import * as configUtils from "../utils/configUtils";
|
||||
|
||||
// Mock auth dependencies first
|
||||
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
||||
auth: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../lib/oauth-state-machine", () => ({
|
||||
OAuthStateMachine: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../lib/auth", () => ({
|
||||
InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
|
||||
tokens: jest.fn().mockResolvedValue(null),
|
||||
clear: jest.fn(),
|
||||
})),
|
||||
DebugInspectorOAuthClientProvider: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock the config utils
|
||||
jest.mock("../utils/configUtils", () => ({
|
||||
...jest.requireActual("../utils/configUtils"),
|
||||
getMCPProxyAddress: jest.fn(() => "http://localhost:6277"),
|
||||
getMCPProxyAuthToken: jest.fn((config: InspectorConfig) => ({
|
||||
token: config.MCP_PROXY_AUTH_TOKEN.value,
|
||||
header: "X-MCP-Proxy-Auth",
|
||||
})),
|
||||
getInitialTransportType: jest.fn(() => "stdio"),
|
||||
getInitialSseUrl: jest.fn(() => "http://localhost:3001/sse"),
|
||||
getInitialCommand: jest.fn(() => "mcp-server-everything"),
|
||||
getInitialArgs: jest.fn(() => ""),
|
||||
initializeInspectorConfig: jest.fn(() => DEFAULT_INSPECTOR_CONFIG),
|
||||
saveInspectorConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
// Get references to the mocked functions
|
||||
const mockGetMCPProxyAuthToken = configUtils.getMCPProxyAuthToken as jest.Mock;
|
||||
const mockInitializeInspectorConfig =
|
||||
configUtils.initializeInspectorConfig as jest.Mock;
|
||||
|
||||
// Mock other dependencies
|
||||
jest.mock("../lib/hooks/useConnection", () => ({
|
||||
useConnection: () => ({
|
||||
connectionStatus: "disconnected",
|
||||
serverCapabilities: null,
|
||||
mcpClient: null,
|
||||
requestHistory: [],
|
||||
makeRequest: jest.fn(),
|
||||
sendNotification: jest.fn(),
|
||||
handleCompletion: jest.fn(),
|
||||
completionsSupported: false,
|
||||
connect: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("../lib/hooks/useDraggablePane", () => ({
|
||||
useDraggablePane: () => ({
|
||||
height: 300,
|
||||
handleDragStart: jest.fn(),
|
||||
}),
|
||||
useDraggableSidebar: () => ({
|
||||
width: 320,
|
||||
isDragging: false,
|
||||
handleDragStart: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("../components/Sidebar", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>Sidebar</div>,
|
||||
}));
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = jest.fn();
|
||||
|
||||
describe("App - Config Endpoint", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(global.fetch as jest.Mock).mockResolvedValue({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
defaultEnvironment: { TEST_ENV: "test" },
|
||||
defaultCommand: "test-command",
|
||||
defaultArgs: "test-args",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Reset getMCPProxyAuthToken to default behavior
|
||||
mockGetMCPProxyAuthToken.mockImplementation((config: InspectorConfig) => ({
|
||||
token: config.MCP_PROXY_AUTH_TOKEN.value,
|
||||
header: "X-MCP-Proxy-Auth",
|
||||
}));
|
||||
});
|
||||
|
||||
test("sends X-MCP-Proxy-Auth header when fetching config with proxy auth token", async () => {
|
||||
const mockConfig = {
|
||||
...DEFAULT_INSPECTOR_CONFIG,
|
||||
MCP_PROXY_AUTH_TOKEN: {
|
||||
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
|
||||
value: "test-proxy-token",
|
||||
},
|
||||
};
|
||||
|
||||
// Mock initializeInspectorConfig to return our test config
|
||||
mockInitializeInspectorConfig.mockReturnValue(mockConfig);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"http://localhost:6277/config",
|
||||
{
|
||||
headers: {
|
||||
"X-MCP-Proxy-Auth": "Bearer test-proxy-token",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("does not send auth header when proxy auth token is empty", async () => {
|
||||
const mockConfig = {
|
||||
...DEFAULT_INSPECTOR_CONFIG,
|
||||
MCP_PROXY_AUTH_TOKEN: {
|
||||
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
|
||||
value: "",
|
||||
},
|
||||
};
|
||||
|
||||
// Mock initializeInspectorConfig to return our test config
|
||||
mockInitializeInspectorConfig.mockReturnValue(mockConfig);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"http://localhost:6277/config",
|
||||
{
|
||||
headers: {},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("uses custom header name if getMCPProxyAuthToken returns different header", async () => {
|
||||
const mockConfig = {
|
||||
...DEFAULT_INSPECTOR_CONFIG,
|
||||
MCP_PROXY_AUTH_TOKEN: {
|
||||
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
|
||||
value: "test-proxy-token",
|
||||
},
|
||||
};
|
||||
|
||||
// Mock to return a custom header name
|
||||
mockGetMCPProxyAuthToken.mockReturnValue({
|
||||
token: "test-proxy-token",
|
||||
header: "X-Custom-Auth",
|
||||
});
|
||||
mockInitializeInspectorConfig.mockReturnValue(mockConfig);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"http://localhost:6277/config",
|
||||
{
|
||||
headers: {
|
||||
"X-Custom-Auth": "Bearer test-proxy-token",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("config endpoint response updates app state", async () => {
|
||||
const mockConfig = {
|
||||
...DEFAULT_INSPECTOR_CONFIG,
|
||||
MCP_PROXY_AUTH_TOKEN: {
|
||||
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
|
||||
value: "test-proxy-token",
|
||||
},
|
||||
};
|
||||
|
||||
mockInitializeInspectorConfig.mockReturnValue(mockConfig);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Verify the fetch was called with correct parameters
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"http://localhost:6277/config",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"X-MCP-Proxy-Auth": "Bearer test-proxy-token",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("handles config endpoint errors gracefully", async () => {
|
||||
const mockConfig = {
|
||||
...DEFAULT_INSPECTOR_CONFIG,
|
||||
MCP_PROXY_AUTH_TOKEN: {
|
||||
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
|
||||
value: "test-proxy-token",
|
||||
},
|
||||
};
|
||||
|
||||
mockInitializeInspectorConfig.mockReturnValue(mockConfig);
|
||||
|
||||
// Mock fetch to reject
|
||||
(global.fetch as jest.Mock).mockRejectedValue(new Error("Network error"));
|
||||
|
||||
// Spy on console.error
|
||||
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation();
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Error fetching default environment:",
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
@ -666,8 +666,13 @@ const Sidebar = ({
|
|||
switch (connectionStatus) {
|
||||
case "connected":
|
||||
return "Connected";
|
||||
case "error":
|
||||
return "Connection Error, is your MCP server running?";
|
||||
case "error": {
|
||||
const hasProxyToken = config.MCP_PROXY_AUTH_TOKEN?.value;
|
||||
if (!hasProxyToken) {
|
||||
return "Connection Error - Did you add the proxy session token in Configuration?";
|
||||
}
|
||||
return "Connection Error - Check if your MCP server is running and proxy token is correct";
|
||||
}
|
||||
case "error-connecting-to-proxy":
|
||||
return "Error Connecting to MCP Inspector Proxy - Check Console logs";
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -84,12 +84,6 @@ Object.defineProperty(window, "sessionStorage", {
|
|||
value: sessionStorageMock,
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "location", {
|
||||
value: {
|
||||
origin: "http://localhost:3000",
|
||||
},
|
||||
});
|
||||
|
||||
describe("AuthDebugger", () => {
|
||||
const defaultAuthState = EMPTY_DEBUGGER_STATE;
|
||||
|
||||
|
|
@ -104,7 +98,7 @@ describe("AuthDebugger", () => {
|
|||
jest.clearAllMocks();
|
||||
sessionStorageMock.getItem.mockReturnValue(null);
|
||||
|
||||
// Supress
|
||||
// Suppress console errors in tests to avoid JSDOM navigation noise
|
||||
jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata);
|
||||
|
|
@ -447,18 +441,6 @@ describe("AuthDebugger", () => {
|
|||
it("should store auth state to sessionStorage before redirect in Quick OAuth Flow", async () => {
|
||||
const updateAuthState = jest.fn();
|
||||
|
||||
// Mock window.location.href setter
|
||||
const originalLocation = window.location;
|
||||
const locationMock = {
|
||||
...originalLocation,
|
||||
href: "",
|
||||
origin: "http://localhost:3000",
|
||||
};
|
||||
Object.defineProperty(window, "location", {
|
||||
writable: true,
|
||||
value: locationMock,
|
||||
});
|
||||
|
||||
// Setup mocks for OAuth flow
|
||||
mockStartAuthorization.mockResolvedValue({
|
||||
authorizationUrl: new URL(
|
||||
|
|
|
|||
|
|
@ -519,6 +519,7 @@ describe("Sidebar Environment Variables", () => {
|
|||
label: "Request Timeout",
|
||||
description: "Timeout for requests to the MCP server (ms)",
|
||||
value: 5000,
|
||||
is_session_item: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
@ -544,6 +545,7 @@ describe("Sidebar Environment Variables", () => {
|
|||
description:
|
||||
"Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577",
|
||||
value: "http://localhost:8080",
|
||||
is_session_item: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
@ -569,6 +571,7 @@ describe("Sidebar Environment Variables", () => {
|
|||
description:
|
||||
"Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)",
|
||||
value: 10000,
|
||||
is_session_item: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
@ -591,6 +594,7 @@ describe("Sidebar Environment Variables", () => {
|
|||
label: "Request Timeout",
|
||||
description: "Timeout for requests to the MCP server (ms)",
|
||||
value: 0,
|
||||
is_session_item: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
@ -637,6 +641,7 @@ describe("Sidebar Environment Variables", () => {
|
|||
label: "Request Timeout",
|
||||
description: "Timeout for requests to the MCP server (ms)",
|
||||
value: 3000,
|
||||
is_session_item: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ export type ConfigItem = {
|
|||
label: string;
|
||||
description: string;
|
||||
value: string | number | boolean;
|
||||
is_session_item: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -33,4 +34,9 @@ export type InspectorConfig = {
|
|||
* The full address of the MCP Proxy Server, in case it is running on a non-default address. Example: http://10.1.1.22:5577
|
||||
*/
|
||||
MCP_PROXY_FULL_ADDRESS: ConfigItem;
|
||||
|
||||
/**
|
||||
* Session token for authenticating with the MCP Proxy Server. This token is displayed in the proxy server console on startup.
|
||||
*/
|
||||
MCP_PROXY_AUTH_TOKEN: ConfigItem;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -36,22 +36,33 @@ export const DEFAULT_INSPECTOR_CONFIG: InspectorConfig = {
|
|||
label: "Request Timeout",
|
||||
description: "Timeout for requests to the MCP server (ms)",
|
||||
value: 10000,
|
||||
is_session_item: false,
|
||||
},
|
||||
MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS: {
|
||||
label: "Reset Timeout on Progress",
|
||||
description: "Reset timeout on progress notifications",
|
||||
value: true,
|
||||
is_session_item: false,
|
||||
},
|
||||
MCP_REQUEST_MAX_TOTAL_TIMEOUT: {
|
||||
label: "Maximum Total Timeout",
|
||||
description:
|
||||
"Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)",
|
||||
value: 60000,
|
||||
is_session_item: false,
|
||||
},
|
||||
MCP_PROXY_FULL_ADDRESS: {
|
||||
label: "Inspector Proxy Address",
|
||||
description:
|
||||
"Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577",
|
||||
value: "",
|
||||
is_session_item: false,
|
||||
},
|
||||
MCP_PROXY_AUTH_TOKEN: {
|
||||
label: "Proxy Session Token",
|
||||
description:
|
||||
"Session token for authenticating with the MCP Proxy Server (displayed in proxy console on startup)",
|
||||
value: "",
|
||||
is_session_item: true,
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useConnection } from "../useConnection";
|
|||
import { z } from "zod";
|
||||
import { ClientRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { DEFAULT_INSPECTOR_CONFIG } from "../../constants";
|
||||
import { SSEClientTransportOptions } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
|
|
@ -23,21 +24,46 @@ const mockClient = {
|
|||
setRequestHandler: jest.fn(),
|
||||
};
|
||||
|
||||
// Mock transport instances
|
||||
const mockSSETransport: {
|
||||
start: jest.Mock;
|
||||
url: URL | undefined;
|
||||
options: SSEClientTransportOptions | undefined;
|
||||
} = {
|
||||
start: jest.fn(),
|
||||
url: undefined,
|
||||
options: undefined,
|
||||
};
|
||||
|
||||
const mockStreamableHTTPTransport: {
|
||||
start: jest.Mock;
|
||||
url: URL | undefined;
|
||||
options: SSEClientTransportOptions | undefined;
|
||||
} = {
|
||||
start: jest.fn(),
|
||||
url: undefined,
|
||||
options: undefined,
|
||||
};
|
||||
|
||||
jest.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
|
||||
Client: jest.fn().mockImplementation(() => mockClient),
|
||||
}));
|
||||
|
||||
jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({
|
||||
SSEClientTransport: jest.fn((url) => ({
|
||||
toString: () => url,
|
||||
})),
|
||||
SSEClientTransport: jest.fn((url, options) => {
|
||||
mockSSETransport.url = url;
|
||||
mockSSETransport.options = options;
|
||||
return mockSSETransport;
|
||||
}),
|
||||
SseError: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||
StreamableHTTPClientTransport: jest.fn((url) => ({
|
||||
toString: () => url,
|
||||
})),
|
||||
StreamableHTTPClientTransport: jest.fn((url, options) => {
|
||||
mockStreamableHTTPTransport.url = url;
|
||||
mockStreamableHTTPTransport.options = options;
|
||||
return mockStreamableHTTPTransport;
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
||||
|
|
@ -259,4 +285,181 @@ describe("useConnection", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Proxy Authentication Headers", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Reset the mock transport objects
|
||||
mockSSETransport.url = undefined;
|
||||
mockSSETransport.options = undefined;
|
||||
mockStreamableHTTPTransport.url = undefined;
|
||||
mockStreamableHTTPTransport.options = undefined;
|
||||
});
|
||||
|
||||
test("sends X-MCP-Proxy-Auth header when proxy auth token is configured", async () => {
|
||||
const propsWithProxyAuth = {
|
||||
...defaultProps,
|
||||
config: {
|
||||
...DEFAULT_INSPECTOR_CONFIG,
|
||||
MCP_PROXY_AUTH_TOKEN: {
|
||||
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
|
||||
value: "test-proxy-token",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useConnection(propsWithProxyAuth));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
// Check that the transport was created with the correct headers
|
||||
expect(mockSSETransport.options).toBeDefined();
|
||||
expect(mockSSETransport.options?.requestInit).toBeDefined();
|
||||
|
||||
expect(mockSSETransport.options?.requestInit?.headers).toHaveProperty(
|
||||
"X-MCP-Proxy-Auth",
|
||||
"Bearer test-proxy-token",
|
||||
);
|
||||
expect(mockSSETransport?.options?.eventSourceInit?.fetch).toBeDefined();
|
||||
|
||||
// Verify the fetch function includes the proxy auth header
|
||||
const mockFetch = mockSSETransport.options?.eventSourceInit?.fetch;
|
||||
const testUrl = "http://test.com";
|
||||
await mockFetch?.(testUrl, {
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
cache: "no-store",
|
||||
mode: "cors",
|
||||
signal: new AbortController().signal,
|
||||
redirect: "follow",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
(global.fetch as jest.Mock).mock.calls[0][1].headers,
|
||||
).toHaveProperty("X-MCP-Proxy-Auth", "Bearer test-proxy-token");
|
||||
expect((global.fetch as jest.Mock).mock.calls[1][0]).toBe(testUrl);
|
||||
expect(
|
||||
(global.fetch as jest.Mock).mock.calls[1][1].headers,
|
||||
).toHaveProperty("X-MCP-Proxy-Auth", "Bearer test-proxy-token");
|
||||
});
|
||||
|
||||
test("does NOT send Authorization header for proxy auth", async () => {
|
||||
const propsWithProxyAuth = {
|
||||
...defaultProps,
|
||||
config: {
|
||||
...DEFAULT_INSPECTOR_CONFIG,
|
||||
proxyAuthToken: "test-proxy-token",
|
||||
},
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useConnection(propsWithProxyAuth));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
// Check that Authorization header is NOT used for proxy auth
|
||||
expect(mockSSETransport.options?.requestInit?.headers).not.toHaveProperty(
|
||||
"Authorization",
|
||||
"Bearer test-proxy-token",
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves server Authorization header when proxy auth is configured", async () => {
|
||||
const propsWithBothAuth = {
|
||||
...defaultProps,
|
||||
bearerToken: "server-auth-token",
|
||||
config: {
|
||||
...DEFAULT_INSPECTOR_CONFIG,
|
||||
MCP_PROXY_AUTH_TOKEN: {
|
||||
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
|
||||
value: "test-proxy-token",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useConnection(propsWithBothAuth));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
// Check that both headers are present and distinct
|
||||
const headers = mockSSETransport.options?.requestInit?.headers;
|
||||
expect(headers).toHaveProperty(
|
||||
"Authorization",
|
||||
"Bearer server-auth-token",
|
||||
);
|
||||
expect(headers).toHaveProperty(
|
||||
"X-MCP-Proxy-Auth",
|
||||
"Bearer test-proxy-token",
|
||||
);
|
||||
});
|
||||
|
||||
test("sends X-MCP-Proxy-Auth in health check requests", async () => {
|
||||
const fetchMock = global.fetch as jest.Mock;
|
||||
fetchMock.mockClear();
|
||||
|
||||
const propsWithProxyAuth = {
|
||||
...defaultProps,
|
||||
config: {
|
||||
...DEFAULT_INSPECTOR_CONFIG,
|
||||
MCP_PROXY_AUTH_TOKEN: {
|
||||
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
|
||||
value: "test-proxy-token",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useConnection(propsWithProxyAuth));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
// Find the health check call
|
||||
const healthCheckCall = fetchMock.mock.calls.find(
|
||||
(call) => call[0].pathname === "/health",
|
||||
);
|
||||
|
||||
expect(healthCheckCall).toBeDefined();
|
||||
expect(healthCheckCall[1].headers).toHaveProperty(
|
||||
"X-MCP-Proxy-Auth",
|
||||
"Bearer test-proxy-token",
|
||||
);
|
||||
});
|
||||
|
||||
test("works correctly with streamable-http transport", async () => {
|
||||
const propsWithStreamableHttp = {
|
||||
...defaultProps,
|
||||
transportType: "streamable-http" as const,
|
||||
config: {
|
||||
...DEFAULT_INSPECTOR_CONFIG,
|
||||
MCP_PROXY_AUTH_TOKEN: {
|
||||
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
|
||||
value: "test-proxy-token",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useConnection(propsWithStreamableHttp),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
// Check that the streamable HTTP transport was created with the correct headers
|
||||
expect(mockStreamableHTTPTransport.options).toBeDefined();
|
||||
expect(
|
||||
mockStreamableHTTPTransport.options?.requestInit?.headers,
|
||||
).toHaveProperty("X-MCP-Proxy-Auth", "Bearer test-proxy-token");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import {
|
|||
getMCPProxyAddress,
|
||||
getMCPServerRequestMaxTotalTimeout,
|
||||
resetRequestTimeoutOnProgress,
|
||||
getMCPProxyAuthToken,
|
||||
} from "@/utils/configUtils";
|
||||
import { getMCPServerRequestTimeout } from "@/utils/configUtils";
|
||||
import { InspectorConfig } from "../configurationTypes";
|
||||
|
|
@ -242,7 +243,13 @@ export function useConnection({
|
|||
const checkProxyHealth = async () => {
|
||||
try {
|
||||
const proxyHealthUrl = new URL(`${getMCPProxyAddress(config)}/health`);
|
||||
const proxyHealthResponse = await fetch(proxyHealthUrl);
|
||||
const { token: proxyAuthToken, header: proxyAuthTokenHeader } =
|
||||
getMCPProxyAuthToken(config);
|
||||
const headers: HeadersInit = {};
|
||||
if (proxyAuthToken) {
|
||||
headers[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`;
|
||||
}
|
||||
const proxyHealthResponse = await fetch(proxyHealthUrl, { headers });
|
||||
const proxyHealth = await proxyHealthResponse.json();
|
||||
if (proxyHealth?.status !== "ok") {
|
||||
throw new Error("MCP Proxy Server is not healthy");
|
||||
|
|
@ -261,6 +268,13 @@ export function useConnection({
|
|||
);
|
||||
};
|
||||
|
||||
const isProxyAuthError = (error: unknown): boolean => {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
error.message.includes("Authentication required. Use the session token")
|
||||
);
|
||||
};
|
||||
|
||||
const handleAuthError = async (error: unknown) => {
|
||||
if (is401Error(error)) {
|
||||
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
|
||||
|
|
@ -318,6 +332,14 @@ export function useConnection({
|
|||
}
|
||||
}
|
||||
|
||||
// Add proxy authentication
|
||||
const { token: proxyAuthToken, header: proxyAuthTokenHeader } =
|
||||
getMCPProxyAuthToken(config);
|
||||
const proxyHeaders: HeadersInit = {};
|
||||
if (proxyAuthToken) {
|
||||
proxyHeaders[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`;
|
||||
}
|
||||
|
||||
// Create appropriate transport
|
||||
let transportOptions:
|
||||
| StreamableHTTPClientTransportOptions
|
||||
|
|
@ -335,11 +357,15 @@ export function useConnection({
|
|||
eventSourceInit: {
|
||||
fetch: (
|
||||
url: string | URL | globalThis.Request,
|
||||
init: RequestInit | undefined,
|
||||
) => fetch(url, { ...init, headers }),
|
||||
init?: RequestInit,
|
||||
) =>
|
||||
fetch(url, {
|
||||
...init,
|
||||
headers: { ...headers, ...proxyHeaders },
|
||||
}),
|
||||
},
|
||||
requestInit: {
|
||||
headers,
|
||||
headers: { ...headers, ...proxyHeaders },
|
||||
},
|
||||
};
|
||||
break;
|
||||
|
|
@ -351,11 +377,15 @@ export function useConnection({
|
|||
eventSourceInit: {
|
||||
fetch: (
|
||||
url: string | URL | globalThis.Request,
|
||||
init: RequestInit | undefined,
|
||||
) => fetch(url, { ...init, headers }),
|
||||
init?: RequestInit,
|
||||
) =>
|
||||
fetch(url, {
|
||||
...init,
|
||||
headers: { ...headers, ...proxyHeaders },
|
||||
}),
|
||||
},
|
||||
requestInit: {
|
||||
headers,
|
||||
headers: { ...headers, ...proxyHeaders },
|
||||
},
|
||||
};
|
||||
break;
|
||||
|
|
@ -367,11 +397,15 @@ export function useConnection({
|
|||
eventSourceInit: {
|
||||
fetch: (
|
||||
url: string | URL | globalThis.Request,
|
||||
init: RequestInit | undefined,
|
||||
) => fetch(url, { ...init, headers }),
|
||||
init?: RequestInit,
|
||||
) =>
|
||||
fetch(url, {
|
||||
...init,
|
||||
headers: { ...headers, ...proxyHeaders },
|
||||
}),
|
||||
},
|
||||
requestInit: {
|
||||
headers,
|
||||
headers: { ...headers, ...proxyHeaders },
|
||||
},
|
||||
// TODO these should be configurable...
|
||||
reconnectionOptions: {
|
||||
|
|
@ -447,6 +481,18 @@ export function useConnection({
|
|||
error,
|
||||
);
|
||||
|
||||
// Check if it's a proxy auth error
|
||||
if (isProxyAuthError(error)) {
|
||||
toast({
|
||||
title: "Proxy Authentication Required",
|
||||
description:
|
||||
"Please enter the session token from the proxy server console in the Configuration settings.",
|
||||
variant: "destructive",
|
||||
});
|
||||
setConnectionStatus("error");
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldRetry = await handleAuthError(error);
|
||||
if (shouldRetry) {
|
||||
return connect(undefined, retryCount + 1);
|
||||
|
|
|
|||
72
client/src/utils/__tests__/configUtils.test.ts
Normal file
72
client/src/utils/__tests__/configUtils.test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { getMCPProxyAuthToken } from "../configUtils";
|
||||
import { DEFAULT_INSPECTOR_CONFIG } from "../../lib/constants";
|
||||
import { InspectorConfig } from "../../lib/configurationTypes";
|
||||
|
||||
describe("configUtils", () => {
|
||||
describe("getMCPProxyAuthToken", () => {
|
||||
test("returns token and default header name", () => {
|
||||
const config: InspectorConfig = {
|
||||
...DEFAULT_INSPECTOR_CONFIG,
|
||||
MCP_PROXY_AUTH_TOKEN: {
|
||||
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
|
||||
value: "test-token-123",
|
||||
},
|
||||
};
|
||||
|
||||
const result = getMCPProxyAuthToken(config);
|
||||
|
||||
expect(result).toEqual({
|
||||
token: "test-token-123",
|
||||
header: "X-MCP-Proxy-Auth",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns empty token when not configured", () => {
|
||||
const config: InspectorConfig = {
|
||||
...DEFAULT_INSPECTOR_CONFIG,
|
||||
MCP_PROXY_AUTH_TOKEN: {
|
||||
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
|
||||
value: "",
|
||||
},
|
||||
};
|
||||
|
||||
const result = getMCPProxyAuthToken(config);
|
||||
|
||||
expect(result).toEqual({
|
||||
token: "",
|
||||
header: "X-MCP-Proxy-Auth",
|
||||
});
|
||||
});
|
||||
|
||||
test("always returns X-MCP-Proxy-Auth as header name", () => {
|
||||
const config: InspectorConfig = {
|
||||
...DEFAULT_INSPECTOR_CONFIG,
|
||||
MCP_PROXY_AUTH_TOKEN: {
|
||||
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
|
||||
value: "any-token",
|
||||
},
|
||||
};
|
||||
|
||||
const result = getMCPProxyAuthToken(config);
|
||||
|
||||
expect(result.header).toBe("X-MCP-Proxy-Auth");
|
||||
});
|
||||
|
||||
test("handles null/undefined value gracefully", () => {
|
||||
const config: InspectorConfig = {
|
||||
...DEFAULT_INSPECTOR_CONFIG,
|
||||
MCP_PROXY_AUTH_TOKEN: {
|
||||
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
|
||||
value: null as unknown as string,
|
||||
},
|
||||
};
|
||||
|
||||
const result = getMCPProxyAuthToken(config);
|
||||
|
||||
expect(result).toEqual({
|
||||
token: null,
|
||||
header: "X-MCP-Proxy-Auth",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -28,6 +28,18 @@ export const getMCPServerRequestMaxTotalTimeout = (
|
|||
return config.MCP_REQUEST_MAX_TOTAL_TIMEOUT.value as number;
|
||||
};
|
||||
|
||||
export const getMCPProxyAuthToken = (
|
||||
config: InspectorConfig,
|
||||
): {
|
||||
token: string;
|
||||
header: string;
|
||||
} => {
|
||||
return {
|
||||
token: config.MCP_PROXY_AUTH_TOKEN.value as string,
|
||||
header: "X-MCP-Proxy-Auth",
|
||||
};
|
||||
};
|
||||
|
||||
const getSearchParam = (key: string): string | null => {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
|
|
@ -100,27 +112,67 @@ export const getConfigOverridesFromQueryParams = (
|
|||
export const initializeInspectorConfig = (
|
||||
localStorageKey: string,
|
||||
): InspectorConfig => {
|
||||
const savedConfig = localStorage.getItem(localStorageKey);
|
||||
let baseConfig: InspectorConfig;
|
||||
if (savedConfig) {
|
||||
// merge default config with saved config
|
||||
const mergedConfig = {
|
||||
...DEFAULT_INSPECTOR_CONFIG,
|
||||
...JSON.parse(savedConfig),
|
||||
} as InspectorConfig;
|
||||
// Read persistent config from localStorage
|
||||
const savedPersistentConfig = localStorage.getItem(localStorageKey);
|
||||
// Read ephemeral config from sessionStorage
|
||||
const savedEphemeralConfig = sessionStorage.getItem(
|
||||
`${localStorageKey}_ephemeral`,
|
||||
);
|
||||
|
||||
// update description of keys to match the new description (in case of any updates to the default config description)
|
||||
for (const [key, value] of Object.entries(mergedConfig)) {
|
||||
mergedConfig[key as keyof InspectorConfig] = {
|
||||
...value,
|
||||
label: DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].label,
|
||||
};
|
||||
}
|
||||
baseConfig = mergedConfig;
|
||||
} else {
|
||||
baseConfig = DEFAULT_INSPECTOR_CONFIG;
|
||||
// Start with default config
|
||||
let baseConfig = { ...DEFAULT_INSPECTOR_CONFIG };
|
||||
|
||||
// Apply saved persistent config
|
||||
if (savedPersistentConfig) {
|
||||
const parsedPersistentConfig = JSON.parse(savedPersistentConfig);
|
||||
baseConfig = { ...baseConfig, ...parsedPersistentConfig };
|
||||
}
|
||||
|
||||
// Apply saved ephemeral config
|
||||
if (savedEphemeralConfig) {
|
||||
const parsedEphemeralConfig = JSON.parse(savedEphemeralConfig);
|
||||
baseConfig = { ...baseConfig, ...parsedEphemeralConfig };
|
||||
}
|
||||
|
||||
// Ensure all config items have the latest labels/descriptions from defaults
|
||||
for (const [key, value] of Object.entries(baseConfig)) {
|
||||
baseConfig[key as keyof InspectorConfig] = {
|
||||
...value,
|
||||
label: DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].label,
|
||||
description:
|
||||
DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].description,
|
||||
is_session_item:
|
||||
DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].is_session_item,
|
||||
};
|
||||
}
|
||||
|
||||
// Apply query param overrides
|
||||
const overrides = getConfigOverridesFromQueryParams(DEFAULT_INSPECTOR_CONFIG);
|
||||
return { ...baseConfig, ...overrides };
|
||||
};
|
||||
|
||||
export const saveInspectorConfig = (
|
||||
localStorageKey: string,
|
||||
config: InspectorConfig,
|
||||
): void => {
|
||||
const persistentConfig: Partial<InspectorConfig> = {};
|
||||
const ephemeralConfig: Partial<InspectorConfig> = {};
|
||||
|
||||
// Split config based on is_session_item flag
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (value.is_session_item) {
|
||||
ephemeralConfig[key as keyof InspectorConfig] = value;
|
||||
} else {
|
||||
persistentConfig[key as keyof InspectorConfig] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Save persistent config to localStorage
|
||||
localStorage.setItem(localStorageKey, JSON.stringify(persistentConfig));
|
||||
|
||||
// Save ephemeral config to sessionStorage
|
||||
sessionStorage.setItem(
|
||||
`${localStorageKey}_ephemeral`,
|
||||
JSON.stringify(ephemeralConfig),
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue