Merge branch 'main' into feat/prompt-layout

This commit is contained in:
Cliff Hall 2025-07-03 17:13:48 -04:00 • committed by GitHub
commit 1cf3474c93
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 3095 additions and 602 deletions

View file

@ -1,5 +1,6 @@
#!/usr/bin/env node
import open from "open";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
import handler from "serve-handler";
@ -39,19 +40,23 @@ const server = http.createServer((request, response) => {
return handler(request, response, handlerOptions);
});
const port = process.env.PORT || 6274;
const port = parseInt(process.env.CLIENT_PORT || "6274", 10);
const host = process.env.HOST || "localhost";
server.on("listening", () => {
console.log(
`🔍 MCP Inspector is up and running at http://127.0.0.1:${port} 🚀`,
);
const url = process.env.INSPECTOR_URL || `http://${host}:${port}`;
console.log(`\n🚀 MCP Inspector is up and running at:\n ${url}\n`);
if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") {
console.log(`🌐 Opening browser...`);
open(url);
}
});
server.on("error", (err) => {
if (err.message.includes(`EADDRINUSE`)) {
console.error(
`❌ MCP Inspector PORT IS IN USE at http://127.0.0.1:${port} ❌ `,
`❌ MCP Inspector PORT IS IN USE at http://${host}:${port} ❌ `,
);
} else {
throw err;
}
});
server.listen(port);
server.listen(port, host);

View file

@ -2,15 +2,202 @@
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));
const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277";
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms, true));
}
function getClientUrl(port, authDisabled, sessionToken, serverPort) {
const host = process.env.HOST || "localhost";
const baseUrl = `http://${host}:${port}`;
const params = new URLSearchParams();
if (serverPort && serverPort !== DEFAULT_MCP_PROXY_LISTEN_PORT) {
params.set("MCP_PROXY_PORT", serverPort);
}
if (!authDisabled) {
params.set("MCP_PROXY_AUTH_TOKEN", sessionToken);
}
return params.size > 0 ? `${baseUrl}/?${params.toString()}` : baseUrl;
}
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,
SERVER_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,
SERVER_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,
SERVER_PORT,
authDisabled,
sessionToken,
abort,
cancelled,
} = clientOptions;
const clientCommand = "npx";
const host = process.env.HOST || "localhost";
const clientArgs = ["vite", "--port", CLIENT_PORT, "--host", host];
const client = spawn(clientCommand, clientArgs, {
cwd: resolve(__dirname, ".."),
env: { ...process.env, CLIENT_PORT },
signal: abort.signal,
echoOutput: true,
});
const url = getClientUrl(
CLIENT_PORT,
authDisabled,
sessionToken,
SERVER_PORT,
);
// Give vite time to start before opening or logging the URL
setTimeout(() => {
console.log(`\n🚀 MCP Inspector is up and running at:\n ${url}\n`);
if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") {
console.log("🌐 Opening browser...");
open(url);
}
}, 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,
SERVER_PORT,
authDisabled,
sessionToken,
abort,
cancelled,
} = clientOptions;
const inspectorClientPath = resolve(
__dirname,
"../..",
"client",
"bin",
"client.js",
);
const url = getClientUrl(
CLIENT_PORT,
authDisabled,
sessionToken,
SERVER_PORT,
);
await spawnPromise("node", [inspectorClientPath], {
env: {
...process.env,
CLIENT_PORT,
INSPECTOR_URL: url,
},
signal: abort.signal,
echoOutput: true,
});
}
async function main() {
// Parse command line arguments
const args = process.argv.slice(2);
@ -18,6 +205,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 +215,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 +231,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";
const SERVER_PORT = process.env.SERVER_PORT ?? DEFAULT_MCP_PROXY_LISTEN_PORT;
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 +258,42 @@ 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,
SERVER_PORT,
authDisabled,
sessionToken,
abort,
cancelled,
};
await (isDev
? startDevClient(clientOptions)
: startProdClient(clientOptions));
} catch (e) {
if (!cancelled || process.env.DEBUG) throw e;
}

View file

@ -0,0 +1,18 @@
import { rimraf } from "rimraf";
async function globalTeardown() {
if (!process.env.CI) {
console.log("Cleaning up test-results directory...");
// Add a small delay to ensure all Playwright files are written
await new Promise((resolve) => setTimeout(resolve, 100));
await rimraf("./e2e/test-results");
console.log("Test-results directory cleaned up.");
}
}
export default globalTeardown;
// Call the function when this script is run directly
if (import.meta.url === `file://${process.argv[1]}`) {
globalTeardown().catch(console.error);
}

View file

@ -0,0 +1,113 @@
import { test, expect } from "@playwright/test";
// Adjust the URL if your dev server runs on a different port
const APP_URL = "http://localhost:6274/";
test.describe("Transport Type Dropdown", () => {
test("should have options for STDIO, SSE, and Streamable HTTP", async ({
page,
}) => {
await page.goto(APP_URL);
// Wait for the Transport Type dropdown to be visible
const selectTrigger = page.getByLabel("Transport Type");
await expect(selectTrigger).toBeVisible();
// Open the dropdown
await selectTrigger.click();
// Check for the three options
await expect(page.getByRole("option", { name: "STDIO" })).toBeVisible();
await expect(page.getByRole("option", { name: "SSE" })).toBeVisible();
await expect(
page.getByRole("option", { name: "Streamable HTTP" }),
).toBeVisible();
});
test("should show Command and Arguments fields and hide URL field when Transport Type is STDIO", async ({
page,
}) => {
await page.goto(APP_URL);
// Wait for the Transport Type dropdown to be visible
const selectTrigger = page.getByLabel("Transport Type");
await expect(selectTrigger).toBeVisible();
// Open the dropdown and select STDIO
await selectTrigger.click();
await page.getByRole("option", { name: "STDIO" }).click();
// Wait for the form to update
await page.waitForTimeout(100);
// Check that Command and Arguments fields are visible
await expect(page.locator("#command-input")).toBeVisible();
await expect(page.locator("#arguments-input")).toBeVisible();
// Check that URL field is not visible
await expect(page.locator("#sse-url-input")).not.toBeVisible();
// Also verify the labels are present
await expect(page.getByText("Command")).toBeVisible();
await expect(page.getByText("Arguments")).toBeVisible();
await expect(page.getByText("URL")).not.toBeVisible();
});
test("should show URL field and hide Command and Arguments fields when Transport Type is SSE", async ({
page,
}) => {
await page.goto(APP_URL);
// Wait for the Transport Type dropdown to be visible
const selectTrigger = page.getByLabel("Transport Type");
await expect(selectTrigger).toBeVisible();
// Open the dropdown and select SSE
await selectTrigger.click();
await page.getByRole("option", { name: "SSE" }).click();
// Wait for the form to update
await page.waitForTimeout(100);
// Check that URL field is visible
await expect(page.locator("#sse-url-input")).toBeVisible();
// Check that Command and Arguments fields are not visible
await expect(page.locator("#command-input")).not.toBeVisible();
await expect(page.locator("#arguments-input")).not.toBeVisible();
// Also verify the labels are present/absent
await expect(page.getByText("URL")).toBeVisible();
await expect(page.getByText("Command")).not.toBeVisible();
await expect(page.getByText("Arguments")).not.toBeVisible();
});
test("should show URL field and hide Command and Arguments fields when Transport Type is Streamable HTTP", async ({
page,
}) => {
await page.goto(APP_URL);
// Wait for the Transport Type dropdown to be visible
const selectTrigger = page.getByLabel("Transport Type");
await expect(selectTrigger).toBeVisible();
// Open the dropdown and select Streamable HTTP
await selectTrigger.click();
await page.getByRole("option", { name: "Streamable HTTP" }).click();
// Wait for the form to update
await page.waitForTimeout(100);
// Check that URL field is visible
await expect(page.locator("#sse-url-input")).toBeVisible();
// Check that Command and Arguments fields are not visible
await expect(page.locator("#command-input")).not.toBeVisible();
await expect(page.locator("#arguments-input")).not.toBeVisible();
// Also verify the labels are present/absent
await expect(page.getByText("URL")).toBeVisible();
await expect(page.getByText("Command")).not.toBeVisible();
await expect(page.getByText("Arguments")).not.toBeVisible();
});
});

View file

@ -7,7 +7,7 @@
<title>MCP Inspector</title>
</head>
<body>
<div id="root"></div>
<div id="root" class="w-full"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -21,6 +21,7 @@ module.exports = {
"/node_modules/",
"/dist/",
"/bin/",
"/e2e/",
"\\.config\\.(js|ts|cjs|mjs)$",
],
// Exclude the same patterns from coverage reports
@ -28,6 +29,7 @@ module.exports = {
"/node_modules/",
"/dist/",
"/bin/",
"/e2e/",
"\\.config\\.(js|ts|cjs|mjs)$",
],
};

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-client",
"version": "0.14.0",
"version": "0.15.0",
"description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@ -20,10 +20,12 @@
"lint": "eslint .",
"preview": "vite preview --port 6274",
"test": "jest --config jest.config.cjs",
"test:watch": "jest --config jest.config.cjs --watch"
"test:watch": "jest --config jest.config.cjs --watch",
"test:e2e": "playwright test e2e && npm run cleanup:e2e",
"cleanup:e2e": "node e2e/global-teardown.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
"@modelcontextprotocol/sdk": "^1.13.1",
"@radix-ui/react-checkbox": "^1.1.4",
"ajv": "^6.12.6",
"@radix-ui/react-dialog": "^1.1.3",
@ -38,7 +40,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
"lucide-react": "^0.447.0",
"lucide-react": "^0.523.0",
"pkce-challenge": "^4.1.0",
"prismjs": "^1.30.0",
"react": "^18.3.1",

View file

@ -0,0 +1,70 @@
import { defineConfig, devices } from "@playwright/test";
/**
* @see https://playwright.dev/docs/test-configuration
*/
export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: {
cwd: "..",
command: "npm run dev",
url: "http://localhost:6274",
reuseExistingServer: !process.env.CI,
},
testDir: "./e2e",
outputDir: "./e2e/test-results",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: process.env.CI
? [
["html", { outputFolder: "playwright-report" }],
["json", { outputFile: "results.json" }],
["line"],
]
: [["line"]],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://localhost:6274",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
/* Take screenshots on failure */
screenshot: "only-on-failure",
/* Record video on failure */
video: "retain-on-failure",
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
// Skip WebKit on macOS due to compatibility issues
...(process.platform !== "darwin"
? [
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
]
: []),
],
});

View file

@ -1,7 +1,3 @@
#root {
margin: 0 auto;
}
.logo {
height: 6em;
padding: 1.5em;

View file

@ -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;

View 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();
});
});

View file

@ -16,10 +16,39 @@ interface DynamicJsonFormProps {
const isSimpleObject = (schema: JsonSchemaType): boolean => {
const supportedTypes = ["string", "number", "integer", "boolean", "null"];
if (supportedTypes.includes(schema.type)) return true;
if (schema.type !== "object") return false;
return Object.values(schema.properties ?? {}).every((prop) =>
supportedTypes.includes(prop.type),
);
if (schema.type === "object") {
return Object.values(schema.properties ?? {}).every((prop) =>
supportedTypes.includes(prop.type),
);
}
if (schema.type === "array") {
return !!schema.items && isSimpleObject(schema.items);
}
return false;
};
const getArrayItemDefault = (schema: JsonSchemaType): JsonValue => {
if ("default" in schema && schema.default !== undefined) {
return schema.default;
}
switch (schema.type) {
case "string":
return "";
case "number":
case "integer":
return 0;
case "boolean":
return false;
case "array":
return [];
case "object":
return {};
case "null":
return null;
default:
return null;
}
};
const DynamicJsonForm = ({
@ -113,6 +142,8 @@ const DynamicJsonForm = ({
currentValue: JsonValue,
path: string[] = [],
depth: number = 0,
parentSchema?: JsonSchemaType,
propertyName?: string,
) => {
if (
depth >= maxDepth &&
@ -122,7 +153,8 @@ const DynamicJsonForm = ({
return (
<JsonEditor
value={JSON.stringify(
currentValue ?? generateDefaultValue(propSchema),
currentValue ??
generateDefaultValue(propSchema, propertyName, parentSchema),
null,
2,
)}
@ -140,6 +172,10 @@ const DynamicJsonForm = ({
);
}
// Check if this property is required in the parent schema
const isRequired =
parentSchema?.required?.includes(propertyName || "") ?? false;
switch (propSchema.type) {
case "string":
return (
@ -148,16 +184,11 @@ const DynamicJsonForm = ({
value={(currentValue as string) ?? ""}
onChange={(e) => {
const val = e.target.value;
// Allow clearing non-required fields by setting undefined
// This preserves the distinction between empty string and unset
if (!val && !propSchema.required) {
handleFieldChange(path, undefined);
} else {
handleFieldChange(path, val);
}
// Always allow setting string values, including empty strings
handleFieldChange(path, val);
}}
placeholder={propSchema.description}
required={propSchema.required}
required={isRequired}
/>
);
case "number":
@ -167,9 +198,7 @@ const DynamicJsonForm = ({
value={(currentValue as number)?.toString() ?? ""}
onChange={(e) => {
const val = e.target.value;
// Allow clearing non-required number fields
// This preserves the distinction between 0 and unset
if (!val && !propSchema.required) {
if (!val && !isRequired) {
handleFieldChange(path, undefined);
} else {
const num = Number(val);
@ -179,7 +208,7 @@ const DynamicJsonForm = ({
}
}}
placeholder={propSchema.description}
required={propSchema.required}
required={isRequired}
/>
);
case "integer":
@ -190,9 +219,7 @@ const DynamicJsonForm = ({
value={(currentValue as number)?.toString() ?? ""}
onChange={(e) => {
const val = e.target.value;
// Allow clearing non-required integer fields
// This preserves the distinction between 0 and unset
if (!val && !propSchema.required) {
if (!val && !isRequired) {
handleFieldChange(path, undefined);
} else {
const num = Number(val);
@ -203,7 +230,7 @@ const DynamicJsonForm = ({
}
}}
placeholder={propSchema.description}
required={propSchema.required}
required={isRequired}
/>
);
case "boolean":
@ -213,9 +240,135 @@ const DynamicJsonForm = ({
checked={(currentValue as boolean) ?? false}
onChange={(e) => handleFieldChange(path, e.target.checked)}
className="w-4 h-4"
required={propSchema.required}
required={isRequired}
/>
);
case "object":
if (!propSchema.properties) {
return (
<JsonEditor
value={JSON.stringify(currentValue ?? {}, null, 2)}
onChange={(newValue) => {
try {
const parsed = JSON.parse(newValue);
handleFieldChange(path, parsed);
setJsonError(undefined);
} catch (err) {
setJsonError(
err instanceof Error ? err.message : "Invalid JSON",
);
}
}}
error={jsonError}
/>
);
}
return (
<div className="space-y-2 border rounded p-3">
{Object.entries(propSchema.properties).map(([key, subSchema]) => (
<div key={key}>
<label className="block text-sm font-medium mb-1">
{key}
{propSchema.required?.includes(key) && (
<span className="text-red-500 ml-1">*</span>
)}
</label>
{renderFormFields(
subSchema as JsonSchemaType,
(currentValue as Record<string, JsonValue>)?.[key],
[...path, key],
depth + 1,
propSchema,
key,
)}
</div>
))}
</div>
);
case "array": {
const arrayValue = Array.isArray(currentValue) ? currentValue : [];
if (!propSchema.items) return null;
// If the array items are simple, render as form fields, otherwise use JSON editor
if (isSimpleObject(propSchema.items)) {
return (
<div className="space-y-4">
{propSchema.description && (
<p className="text-sm text-gray-600">
{propSchema.description}
</p>
)}
{propSchema.items?.description && (
<p className="text-sm text-gray-500">
Items: {propSchema.items.description}
</p>
)}
<div className="space-y-2">
{arrayValue.map((item, index) => (
<div key={index} className="flex items-center gap-2">
{renderFormFields(
propSchema.items as JsonSchemaType,
item,
[...path, index.toString()],
depth + 1,
)}
<Button
variant="outline"
size="sm"
onClick={() => {
const newArray = [...arrayValue];
newArray.splice(index, 1);
handleFieldChange(path, newArray);
}}
>
Remove
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => {
const defaultValue = getArrayItemDefault(
propSchema.items as JsonSchemaType,
);
handleFieldChange(path, [...arrayValue, defaultValue]);
}}
title={
propSchema.items?.description
? `Add new ${propSchema.items.description}`
: "Add new item"
}
>
Add Item
</Button>
</div>
</div>
);
}
// For complex arrays, fall back to JSON editor
return (
<JsonEditor
value={JSON.stringify(currentValue ?? [], null, 2)}
onChange={(newValue) => {
try {
const parsed = JSON.parse(newValue);
handleFieldChange(path, parsed);
setJsonError(undefined);
} catch (err) {
setJsonError(
err instanceof Error ? err.message : "Invalid JSON",
);
}
}}
error={jsonError}
/>
);
}
default:
return null;
}

View file

@ -156,7 +156,7 @@ export const OAuthFlowProgress = ({
{authState.resourceMetadataError && (
<div className="mt-2 p-3 border border-blue-300 bg-blue-50 rounded-md">
<p className="text-sm font-medium text-blue-700">
ℹ️ No resource metadata available from{" "}
ℹ️ Problem with resource metadata from{" "}
<a
href={
new URL(

View file

@ -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:

View file

@ -7,7 +7,7 @@ import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import DynamicJsonForm from "./DynamicJsonForm";
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
import { generateDefaultValue } from "@/utils/schemaUtils";
import { generateDefaultValue, isPropertyRequired } from "@/utils/schemaUtils";
import {
CompatibilityCallToolResult,
ListToolsResult,
@ -48,7 +48,11 @@ const ToolsTab = ({
selectedTool?.inputSchema.properties ?? [],
).map(([key, value]) => [
key,
generateDefaultValue(value as JsonSchemaType),
generateDefaultValue(
value as JsonSchemaType,
key,
selectedTool?.inputSchema as JsonSchemaType,
),
]);
setParams(Object.fromEntries(params));
}, [selectedTool]);
@ -92,6 +96,9 @@ const ToolsTab = ({
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
([key, value]) => {
const prop = value as JsonSchemaType;
const inputSchema =
selectedTool.inputSchema as JsonSchemaType;
const required = isPropertyRequired(key, inputSchema);
return (
<div key={key}>
<Label
@ -99,6 +106,9 @@ const ToolsTab = ({
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{key}
{required && (
<span className="text-red-500 ml-1">*</span>
)}
</Label>
{prop.type === "boolean" ? (
<div className="flex items-center space-x-2 mt-2">
@ -163,12 +173,13 @@ const ToolsTab = ({
name={key}
placeholder={prop.description}
value={(params[key] as string) ?? ""}
onChange={(e) =>
onChange={(e) => {
const value = e.target.value;
setParams({
...params,
[key]: Number(e.target.value),
})
}
[key]: value === "" ? "" : Number(value),
});
}}
className="mt-1"
/>
) : (

View file

@ -41,6 +41,7 @@ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
startAuthorization: jest.fn(),
exchangeAuthorization: jest.fn(),
discoverOAuthProtectedResourceMetadata: jest.fn(),
selectResourceURL: jest.fn(),
}));
// Import the functions to get their types
@ -84,17 +85,11 @@ Object.defineProperty(window, "sessionStorage", {
value: sessionStorageMock,
});
Object.defineProperty(window, "location", {
value: {
origin: "http://localhost:3000",
},
});
describe("AuthDebugger", () => {
const defaultAuthState = EMPTY_DEBUGGER_STATE;
const defaultProps = {
serverUrl: "https://example.com",
serverUrl: "https://example.com/mcp",
onBack: jest.fn(),
authState: defaultAuthState,
updateAuthState: jest.fn(),
@ -104,7 +99,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);
@ -209,7 +204,7 @@ describe("AuthDebugger", () => {
// Should first discover and save OAuth metadata
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
new URL("https://example.com"),
new URL("https://example.com/"),
);
// Check that updateAuthState was called with the right info message
@ -326,6 +321,7 @@ describe("AuthDebugger", () => {
isInitiatingAuth: false,
resourceMetadata: null,
resourceMetadataError: null,
resource: null,
oauthTokens: null,
oauthStep: "metadata_discovery",
latestError: null,
@ -367,7 +363,7 @@ describe("AuthDebugger", () => {
});
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
new URL("https://example.com"),
new URL("https://example.com/"),
);
});
@ -447,18 +443,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(
@ -514,11 +498,11 @@ describe("AuthDebugger", () => {
it("should successfully fetch and display protected resource metadata", async () => {
const updateAuthState = jest.fn();
const mockResourceMetadata = {
resource: "https://example.com/api",
resource: "https://example.com/mcp",
authorization_servers: ["https://custom-auth.example.com"],
bearer_methods_supported: ["header", "body"],
resource_documentation: "https://example.com/api/docs",
resource_policy_uri: "https://example.com/api/policy",
resource_documentation: "https://example.com/mcp/docs",
resource_policy_uri: "https://example.com/mcp/policy",
};
// Mock successful metadata discovery
@ -556,7 +540,7 @@ describe("AuthDebugger", () => {
// Wait for the metadata to be fetched
await waitFor(() => {
expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith(
"https://example.com",
"https://example.com/mcp",
);
});
@ -602,7 +586,7 @@ describe("AuthDebugger", () => {
// Wait for the metadata fetch to fail
await waitFor(() => {
expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith(
"https://example.com",
"https://example.com/mcp",
);
});
@ -612,7 +596,7 @@ describe("AuthDebugger", () => {
expect.objectContaining({
resourceMetadataError: mockError,
// Should use the original server URL as fallback
authServerUrl: new URL("https://example.com"),
authServerUrl: new URL("https://example.com/"),
oauthStep: "client_registration",
}),
);
@ -620,7 +604,7 @@ describe("AuthDebugger", () => {
// Verify that regular OAuth metadata discovery was still called
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
new URL("https://example.com"),
new URL("https://example.com/"),
);
});
});

View file

@ -0,0 +1,256 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, jest } from "@jest/globals";
import DynamicJsonForm from "../DynamicJsonForm";
import type { JsonSchemaType } from "@/utils/jsonUtils";
describe("DynamicJsonForm Array Fields", () => {
const renderSimpleArrayForm = (props = {}) => {
const defaultProps = {
schema: {
type: "array" as const,
description: "Test array field",
items: {
type: "string" as const,
description: "Array item",
},
} satisfies JsonSchemaType,
value: [],
onChange: jest.fn(),
};
return render(<DynamicJsonForm {...defaultProps} {...props} />);
};
const renderComplexArrayForm = (props = {}) => {
const defaultProps = {
schema: {
type: "array" as const,
description: "Test complex array field",
items: {
type: "object" as const,
properties: {
nested: { type: "object" as const },
},
},
} satisfies JsonSchemaType,
value: [],
onChange: jest.fn(),
};
return render(<DynamicJsonForm {...defaultProps} {...props} />);
};
describe("Simple Array Rendering", () => {
it("should render form fields for simple array items", () => {
renderSimpleArrayForm({ value: ["item1", "item2"] });
// Should show array description
expect(screen.getByText("Test array field")).toBeDefined();
expect(screen.getByText("Items: Array item")).toBeDefined();
// Should show input fields for each item
const inputs = screen.getAllByRole("textbox");
expect(inputs).toHaveLength(2);
expect(inputs[0]).toHaveProperty("value", "item1");
expect(inputs[1]).toHaveProperty("value", "item2");
// Should show remove buttons
const removeButtons = screen.getAllByText("Remove");
expect(removeButtons).toHaveLength(2);
// Should show add button
expect(screen.getByText("Add Item")).toBeDefined();
});
it("should add new items when Add Item button is clicked", () => {
const onChange = jest.fn();
renderSimpleArrayForm({ value: ["item1"], onChange });
const addButton = screen.getByText("Add Item");
fireEvent.click(addButton);
expect(onChange).toHaveBeenCalledWith(["item1", ""]);
});
it("should remove items when Remove button is clicked", () => {
const onChange = jest.fn();
renderSimpleArrayForm({ value: ["item1", "item2"], onChange });
const removeButtons = screen.getAllByText("Remove");
fireEvent.click(removeButtons[0]);
expect(onChange).toHaveBeenCalledWith(["item2"]);
});
it("should update item values when input changes", () => {
const onChange = jest.fn();
renderSimpleArrayForm({ value: ["item1"], onChange });
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "updated item" } });
expect(onChange).toHaveBeenCalledWith(["updated item"]);
});
it("should handle empty arrays", () => {
renderSimpleArrayForm({ value: [] });
// Should show description and add button but no items
expect(screen.getByText("Test array field")).toBeDefined();
expect(screen.getByText("Add Item")).toBeDefined();
expect(screen.queryByText("Remove")).toBeNull();
});
});
describe("Complex Array Fallback", () => {
it("should render JSON editor for complex arrays", () => {
renderComplexArrayForm();
// Should render as JSON editor (textarea)
const textarea = screen.getByRole("textbox");
expect(textarea).toHaveProperty("type", "textarea");
// Should not show form-specific array controls
expect(screen.queryByText("Add Item")).toBeNull();
expect(screen.queryByText("Remove")).toBeNull();
});
});
describe("Array Type Detection", () => {
it("should detect string arrays as simple", () => {
const schema = {
type: "array" as const,
items: { type: "string" as const },
};
renderSimpleArrayForm({ schema, value: ["test"] });
// Should render form fields, not JSON editor
expect(screen.getByRole("textbox")).not.toHaveProperty(
"type",
"textarea",
);
});
it("should detect number arrays as simple", () => {
const schema = {
type: "array" as const,
items: { type: "number" as const },
};
renderSimpleArrayForm({ schema, value: [1, 2] });
// Should render form fields (number inputs)
const inputs = screen.getAllByRole("spinbutton");
expect(inputs).toHaveLength(2);
});
it("should detect boolean arrays as simple", () => {
const schema = {
type: "array" as const,
items: { type: "boolean" as const },
};
renderSimpleArrayForm({ schema, value: [true, false] });
// Should render form fields (checkboxes)
const checkboxes = screen.getAllByRole("checkbox");
expect(checkboxes).toHaveLength(2);
});
it("should detect simple object arrays as simple", () => {
const schema = {
type: "array" as const,
items: {
type: "object" as const,
properties: {
name: { type: "string" as const },
age: { type: "number" as const },
},
},
};
renderSimpleArrayForm({ schema, value: [{ name: "John", age: 30 }] });
// Should render form fields for simple objects
expect(screen.getByText("Add Item")).toBeDefined();
expect(screen.getByText("Remove")).toBeDefined();
});
});
describe("Array with Different Item Types", () => {
it("should handle integer array items", () => {
const schema = {
type: "array" as const,
items: { type: "integer" as const },
};
const onChange = jest.fn();
renderSimpleArrayForm({ schema, value: [1, 2], onChange });
const inputs = screen.getAllByRole("spinbutton");
expect(inputs).toHaveLength(2);
expect(inputs[0]).toHaveProperty("value", "1");
expect(inputs[1]).toHaveProperty("value", "2");
// Test adding new integer item
const addButton = screen.getByText("Add Item");
fireEvent.click(addButton);
expect(onChange).toHaveBeenCalledWith([1, 2, 0]);
});
it("should handle boolean array items", () => {
const schema = {
type: "array" as const,
items: { type: "boolean" as const },
};
const onChange = jest.fn();
renderSimpleArrayForm({ schema, value: [true, false], onChange });
const checkboxes = screen.getAllByRole("checkbox");
expect(checkboxes).toHaveLength(2);
expect(checkboxes[0]).toHaveProperty("checked", true);
expect(checkboxes[1]).toHaveProperty("checked", false);
// Test adding new boolean item
const addButton = screen.getByText("Add Item");
fireEvent.click(addButton);
expect(onChange).toHaveBeenCalledWith([true, false, false]);
});
});
describe("Array Item Descriptions", () => {
it("should show item description when available", () => {
const schema = {
type: "array" as const,
description: "List of names",
items: {
type: "string" as const,
description: "Person name",
},
};
renderSimpleArrayForm({ schema });
expect(screen.getByText("List of names")).toBeDefined();
expect(screen.getByText("Items: Person name")).toBeDefined();
});
it("should use item description in add button title", () => {
const schema = {
type: "array" as const,
items: {
type: "string" as const,
description: "Email address",
},
};
renderSimpleArrayForm({ schema });
const addButton = screen.getByText("Add Item");
expect(addButton).toHaveProperty("title", "Add new Email address");
});
it("should use default title when no item description", () => {
const schema = {
type: "array" as const,
items: { type: "string" as const },
};
renderSimpleArrayForm({ schema });
const addButton = screen.getByText("Add Item");
expect(addButton).toHaveProperty("title", "Add new item");
});
});
});

View file

@ -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,
},
}),
);

View file

@ -111,6 +111,27 @@ describe("ToolsTab", () => {
});
});
it("should allow typing negative numbers", async () => {
renderToolsTab({
selectedTool: mockTools[0],
});
const input = screen.getByRole("spinbutton") as HTMLInputElement;
// Complete the negative number
fireEvent.change(input, { target: { value: "-42" } });
expect(input.value).toBe("-42");
const submitButton = screen.getByRole("button", { name: /run tool/i });
await act(async () => {
fireEvent.click(submitButton);
});
expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[0].name, {
num: -42,
});
});
it("should disable button and change text while tool is running", async () => {
// Create a promise that we can resolve later
let resolvePromise: ((value: unknown) => void) | undefined;

View file

@ -30,6 +30,7 @@ export interface AuthDebuggerState {
oauthStep: OAuthStep;
resourceMetadata: OAuthProtectedResourceMetadata | null;
resourceMetadataError: Error | null;
resource: URL | null;
authServerUrl: URL | null;
oauthMetadata: OAuthMetadata | null;
oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null;
@ -47,6 +48,7 @@ export const EMPTY_DEBUGGER_STATE: AuthDebuggerState = {
oauthMetadata: null,
resourceMetadata: null,
resourceMetadataError: null,
resource: null,
authServerUrl: null,
oauthClientInfo: null,
authorizationUrl: null,

Some files were not shown because too many files have changed in this diff Show more