First pass at pulling the v1.5 CLI, TUI, and Launcher clients into v2.
This commit is contained in:
parent
d5f12b2a82
commit
467b01edd6
13 changed files with 24043 additions and 203 deletions
2
clients/web/.gitignore
vendored
2
clients/web/.gitignore
vendored
|
|
@ -8,7 +8,7 @@ pnpm-debug.log*
|
|||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
dist-ssr
|
||||
storybook-static
|
||||
coverage
|
||||
|
|
|
|||
809
clients/web/package-lock.json
generated
809
clients/web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,11 +1,19 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/inspector-web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "2.0.0",
|
||||
"type": "module",
|
||||
"main": "build/index.js",
|
||||
"bin": {
|
||||
"mcp-inspector-web": "./build/index.js"
|
||||
},
|
||||
"exports": {
|
||||
".": "./build/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "node --import ./server/quiet-config-warnings.mjs ./node_modules/vite/bin/vite.js",
|
||||
"build": "tsc -b && vite build",
|
||||
"build": "tsc -b && vite build && npm run build:runner",
|
||||
"build:runner": "tsup --config tsup.runner.config.ts",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write src",
|
||||
"format:check": "prettier --check src",
|
||||
|
|
@ -38,6 +46,7 @@
|
|||
"ajv": "^8.17.1",
|
||||
"atomically": "^2.1.1",
|
||||
"chokidar": "^4.0.3",
|
||||
"commander": "^13.1.0",
|
||||
"hono": "^4.12.18",
|
||||
"open": "^10.2.0",
|
||||
"pino": "^9.14.0",
|
||||
|
|
@ -77,6 +86,7 @@
|
|||
"playwright": "^1.58.2",
|
||||
"prettier": "^3.8.1",
|
||||
"storybook": "^10.2.19",
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^8.0.0",
|
||||
|
|
|
|||
161
clients/web/server/run-web.ts
Normal file
161
clients/web/server/run-web.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
/**
|
||||
* Programmatic entry for the launcher and published `mcp-inspector --web`.
|
||||
* Day-to-day dev still uses `npm run dev` (Vite CLI + buildWebServerConfigFromEnv).
|
||||
* Standalone prod uses `node dist/server.js` (env-only, no argv).
|
||||
*/
|
||||
|
||||
import { resolve, join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Command } from "commander";
|
||||
import type { MCPServerConfig } from "../../../core/mcp/types.ts";
|
||||
import {
|
||||
resolveServerConfigs,
|
||||
parseKeyValuePair,
|
||||
parseHeaderPair,
|
||||
type ServerConfigOptions,
|
||||
} from "../../../core/mcp/node/config.ts";
|
||||
import { buildWebServerConfig } from "./web-server-config.js";
|
||||
import { startViteDevServer } from "./start-vite-dev-server.js";
|
||||
import { startHonoServer } from "./server.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function ensureStdioCwd(config: MCPServerConfig): MCPServerConfig {
|
||||
if (
|
||||
(config.type === "stdio" || config.type === undefined) &&
|
||||
config.command &&
|
||||
!config.cwd
|
||||
) {
|
||||
return { ...config, cwd: resolve(process.cwd()) };
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function runWeb(argv: string[]): Promise<number> {
|
||||
const program = new Command();
|
||||
|
||||
const argSeparatorIndex = argv.indexOf("--");
|
||||
let preArgs = argv;
|
||||
let postArgs: string[] = [];
|
||||
if (argSeparatorIndex !== -1) {
|
||||
preArgs = argv.slice(0, argSeparatorIndex);
|
||||
postArgs = argv.slice(argSeparatorIndex + 1);
|
||||
}
|
||||
|
||||
program
|
||||
.name("mcp-inspector-web")
|
||||
.description("Web UI for MCP Inspector")
|
||||
.allowExcessArguments()
|
||||
.allowUnknownOption()
|
||||
.option(
|
||||
"-e <env>",
|
||||
"environment variables in KEY=VALUE format",
|
||||
parseKeyValuePair,
|
||||
{},
|
||||
)
|
||||
.option("--config <path>", "config file path")
|
||||
.option("--server <name>", "server name from config file")
|
||||
.option("--transport <type>", "transport type (stdio, sse, http)")
|
||||
.option("--server-url <url>", "server URL for SSE/HTTP transport")
|
||||
.option("--cwd <path>", "working directory for stdio server process")
|
||||
.option(
|
||||
"--header <headers...>",
|
||||
'HTTP headers as "HeaderName: Value" pairs (for HTTP/SSE transports)',
|
||||
parseHeaderPair,
|
||||
{},
|
||||
)
|
||||
.option("--dev", "run in development mode (Vite)")
|
||||
.parse(preArgs);
|
||||
|
||||
const opts = program.opts() as {
|
||||
config?: string;
|
||||
server?: string;
|
||||
e?: Record<string, string>;
|
||||
transport?: string;
|
||||
serverUrl?: string;
|
||||
cwd?: string;
|
||||
header?: Record<string, string>;
|
||||
dev?: boolean;
|
||||
};
|
||||
|
||||
const args = program.args;
|
||||
const target = [...args, ...postArgs];
|
||||
const isDev = !!opts.dev;
|
||||
|
||||
const hasServerInput =
|
||||
opts.config ||
|
||||
target.length > 0 ||
|
||||
opts.serverUrl ||
|
||||
(opts.transport && opts.transport !== "stdio");
|
||||
|
||||
let initialMcpConfig: MCPServerConfig | null = null;
|
||||
|
||||
if (hasServerInput) {
|
||||
const serverOptions: ServerConfigOptions = {
|
||||
configPath: opts.config,
|
||||
serverName: opts.server,
|
||||
target: target.length > 0 ? target : undefined,
|
||||
transport: opts.transport as "stdio" | "sse" | "http" | undefined,
|
||||
serverUrl: opts.serverUrl,
|
||||
cwd: opts.cwd,
|
||||
env: opts.e,
|
||||
};
|
||||
|
||||
if (opts.header && Object.keys(opts.header).length > 0) {
|
||||
console.warn(
|
||||
"Warning: --header is accepted but initial HTTP headers are configured via server settings in the web UI (post-#1358).",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const configs = resolveServerConfigs(serverOptions, "single");
|
||||
const config = configs[0];
|
||||
if (!config) {
|
||||
console.error(
|
||||
"Error: Could not resolve server config. Use --config and --server, or pass a command/URL.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
initialMcpConfig = ensureStdioCwd(config);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Could not resolve server config.";
|
||||
console.error("Error:", message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const webConfig = buildWebServerConfig({ initialMcpConfig });
|
||||
if (!isDev) {
|
||||
webConfig.staticRoot = join(__dirname, "..", "dist");
|
||||
}
|
||||
|
||||
console.log(
|
||||
isDev
|
||||
? "Starting MCP inspector in development mode..."
|
||||
: "Starting MCP inspector...",
|
||||
);
|
||||
|
||||
let handle: { close(): Promise<void> };
|
||||
|
||||
try {
|
||||
if (isDev) {
|
||||
handle = await startViteDevServer(webConfig);
|
||||
} else {
|
||||
handle = await startHonoServer(webConfig);
|
||||
}
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Web client failed to start.";
|
||||
console.error("Error:", message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const shutdown = () => {
|
||||
void handle.close().then(() => process.exit(0));
|
||||
};
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
return new Promise<number>(() => {});
|
||||
}
|
||||
|
|
@ -149,10 +149,18 @@ export function printServerBanner(
|
|||
return url;
|
||||
}
|
||||
|
||||
export interface BuildWebServerConfigOptions {
|
||||
initialMcpConfig?: MCPServerConfig | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build WebServerConfig from process.env. Used when running server as standalone (e.g. node dist/server.js).
|
||||
* Build WebServerConfig from process.env and optional initial MCP server config.
|
||||
* Used by the launcher runner, Vite dev (`buildWebServerConfigFromEnv`), and prod standalone.
|
||||
*/
|
||||
export function buildWebServerConfigFromEnv(): WebServerConfig {
|
||||
export function buildWebServerConfig(
|
||||
options: BuildWebServerConfigOptions = {},
|
||||
): WebServerConfig {
|
||||
const { initialMcpConfig = null } = options;
|
||||
const port = parseInt(process.env.CLIENT_PORT ?? "6274", 10);
|
||||
const hostname = process.env.HOST ?? "localhost";
|
||||
const baseUrl = `http://${hostname}:${port}`;
|
||||
|
|
@ -163,8 +171,6 @@ export function buildWebServerConfigFromEnv(): WebServerConfig {
|
|||
(process.env[LEGACY_AUTH_TOKEN_ENV] as string | undefined) ??
|
||||
"");
|
||||
|
||||
const initialMcpConfig: MCPServerConfig | null = null;
|
||||
|
||||
const sandboxPort = resolveSandboxPort();
|
||||
|
||||
let logger: Logger | undefined;
|
||||
|
|
@ -196,6 +202,13 @@ export function buildWebServerConfigFromEnv(): WebServerConfig {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build WebServerConfig from process.env only. Used when running server as standalone (e.g. node dist/server.js) or via `vite dev`.
|
||||
*/
|
||||
export function buildWebServerConfigFromEnv(): WebServerConfig {
|
||||
return buildWebServerConfig({ initialMcpConfig: null });
|
||||
}
|
||||
|
||||
/**
|
||||
* Three-way resolution for `autoOpen`:
|
||||
* - explicit `MCP_AUTO_OPEN_ENABLED=true` → always open (overrides VITEST)
|
||||
|
|
|
|||
21
clients/web/src/index.ts
Normal file
21
clients/web/src/index.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { runWeb } from "../server/run-web.js";
|
||||
|
||||
export { runWeb };
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const isMain =
|
||||
process.argv[1] !== undefined &&
|
||||
resolve(process.argv[1]) === resolve(__filename);
|
||||
|
||||
if (isMain) {
|
||||
runWeb(process.argv)
|
||||
.then((code) => process.exit(code ?? 0))
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
27
clients/web/src/test/core/logging/logger.test.ts
Normal file
27
clients/web/src/test/core/logging/logger.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { silentLogger } from "../../../../../../core/logging/logger.js";
|
||||
|
||||
describe("silentLogger", () => {
|
||||
it("reports silent level", () => {
|
||||
expect(silentLogger.level).toBe("silent");
|
||||
});
|
||||
|
||||
it("accepts log calls at every level without throwing", () => {
|
||||
expect(() => {
|
||||
silentLogger.fatal("fatal");
|
||||
silentLogger.error("error");
|
||||
silentLogger.warn("warn");
|
||||
silentLogger.info("info");
|
||||
silentLogger.debug("debug");
|
||||
silentLogger.trace("trace");
|
||||
silentLogger.silent("silent");
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("child returns a logger that remains silent", () => {
|
||||
const child = silentLogger.child({ component: "test" });
|
||||
expect(child.level).toBe("silent");
|
||||
expect(() => child.info("nested")).not.toThrow();
|
||||
expect(child.child()).toBe(child);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
|
|
@ -170,10 +170,16 @@ describe("resolveServerConfigs — single mode", () => {
|
|||
).toThrow(/Only stdio transport can be used with local commands/);
|
||||
});
|
||||
|
||||
it("rejects an empty target with no URL", () => {
|
||||
expect(() => resolveServerConfigs({ target: [] }, "single")).toThrow(
|
||||
/Target is required/,
|
||||
);
|
||||
it("uses the default config path when no target or --config is given", () => {
|
||||
const prevHome = process.env.HOME;
|
||||
process.env.HOME = tempDir;
|
||||
try {
|
||||
expect(() => resolveServerConfigs({ target: [] }, "single")).toThrow(
|
||||
/Config file not found/,
|
||||
);
|
||||
} finally {
|
||||
process.env.HOME = prevHome;
|
||||
}
|
||||
});
|
||||
|
||||
it("loads a named server from config", () => {
|
||||
|
|
@ -358,6 +364,26 @@ describe("resolveServerConfigs — multi mode", () => {
|
|||
expect(configs[0]?.type).toBe("stdio");
|
||||
});
|
||||
|
||||
it("loads the default config path when no args are given", () => {
|
||||
const prevHome = process.env.HOME;
|
||||
const homeDir = join(tempDir, "home");
|
||||
mkdirSync(homeDir, { recursive: true });
|
||||
const defaultConfig = join(homeDir, ".mcp-inspector", "mcp.json");
|
||||
mkdirSync(join(homeDir, ".mcp-inspector"), { recursive: true });
|
||||
writeFileSync(
|
||||
defaultConfig,
|
||||
JSON.stringify({ mcpServers: { a: { command: "a" } } }),
|
||||
);
|
||||
process.env.HOME = homeDir;
|
||||
try {
|
||||
const configs = resolveServerConfigs({}, "multi");
|
||||
expect(configs).toHaveLength(1);
|
||||
expect(configs[0]).toMatchObject({ type: "stdio", command: "a" });
|
||||
} finally {
|
||||
process.env.HOME = prevHome;
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects ad-hoc flags alongside a config path", () => {
|
||||
writeFileSync(
|
||||
configPath,
|
||||
|
|
|
|||
325
clients/web/src/test/integration/server/run-web.test.ts
Normal file
325
clients/web/src/test/integration/server/run-web.test.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const { mockClose, startViteDevServer, startHonoServer } = vi.hoisted(() => {
|
||||
const mockClose = vi.fn().mockResolvedValue(undefined);
|
||||
return {
|
||||
mockClose,
|
||||
startViteDevServer: vi.fn().mockResolvedValue({ close: mockClose }),
|
||||
startHonoServer: vi.fn().mockResolvedValue({ close: mockClose }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../server/start-vite-dev-server.js", () => ({
|
||||
startViteDevServer,
|
||||
}));
|
||||
|
||||
vi.mock("../../../../server/server.js", () => ({
|
||||
startHonoServer,
|
||||
}));
|
||||
|
||||
import * as nodeConfig from "../../../../../../core/mcp/node/config.js";
|
||||
import { runWeb } from "../../../../server/run-web.js";
|
||||
|
||||
describe("runWeb", () => {
|
||||
let logLines: string[];
|
||||
let warnLines: string[];
|
||||
let errorLines: string[];
|
||||
let originalLog: typeof console.log;
|
||||
let originalWarn: typeof console.warn;
|
||||
let originalError: typeof console.error;
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let exitCode: number | undefined;
|
||||
const signalHandlers = new Map<string, () => void>();
|
||||
|
||||
beforeEach(() => {
|
||||
startViteDevServer.mockClear();
|
||||
startHonoServer.mockClear();
|
||||
mockClose.mockClear();
|
||||
signalHandlers.clear();
|
||||
exitCode = undefined;
|
||||
|
||||
logLines = [];
|
||||
warnLines = [];
|
||||
errorLines = [];
|
||||
originalLog = console.log;
|
||||
originalWarn = console.warn;
|
||||
originalError = console.error;
|
||||
console.log = (...args: unknown[]) => {
|
||||
logLines.push(args.map(String).join(" "));
|
||||
};
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warnLines.push(args.map(String).join(" "));
|
||||
};
|
||||
console.error = (...args: unknown[]) => {
|
||||
errorLines.push(args.map(String).join(" "));
|
||||
};
|
||||
|
||||
vi.spyOn(process, "on").mockImplementation((event, handler) => {
|
||||
if (event === "SIGINT" || event === "SIGTERM") {
|
||||
signalHandlers.set(event, handler as () => void);
|
||||
}
|
||||
return process;
|
||||
});
|
||||
|
||||
exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
exitCode = Number(code);
|
||||
if (code !== 0) {
|
||||
throw new Error(`process.exit:${String(code)}`);
|
||||
}
|
||||
return undefined as never;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.log = originalLog;
|
||||
console.warn = originalWarn;
|
||||
console.error = originalError;
|
||||
exitSpy.mockRestore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function expectServerStarted(
|
||||
starter: typeof startHonoServer | typeof startViteDevServer,
|
||||
) {
|
||||
await vi.waitFor(() => expect(starter).toHaveBeenCalledTimes(1));
|
||||
}
|
||||
|
||||
it("starts Hono in production mode with no server args", async () => {
|
||||
void runWeb(["node", "run-web"]);
|
||||
await expectServerStarted(startHonoServer);
|
||||
|
||||
expect(startHonoServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ initialMcpConfig: null }),
|
||||
);
|
||||
expect(startHonoServer.mock.calls[0]?.[0]?.staticRoot).toMatch(/dist$/);
|
||||
expect(logLines.some((l) => l.includes("Starting MCP inspector..."))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("starts Vite when --dev is set", async () => {
|
||||
void runWeb(["node", "run-web", "--dev"]);
|
||||
await expectServerStarted(startViteDevServer);
|
||||
|
||||
expect(startViteDevServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ initialMcpConfig: null }),
|
||||
);
|
||||
expect(
|
||||
logLines.some((l) => l.includes("development mode")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves an ad-hoc HTTP URL into initialMcpConfig", async () => {
|
||||
void runWeb([
|
||||
"node",
|
||||
"run-web",
|
||||
"https://example.com/mcp",
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
await expectServerStarted(startHonoServer);
|
||||
|
||||
expect(startHonoServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initialMcpConfig: expect.objectContaining({
|
||||
type: "streamable-http",
|
||||
url: "https://example.com/mcp",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("appends positional args after -- to the ad-hoc target", async () => {
|
||||
void runWeb([
|
||||
"node",
|
||||
"run-web",
|
||||
"--transport",
|
||||
"http",
|
||||
"--",
|
||||
"https://example.com/mcp",
|
||||
]);
|
||||
await expectServerStarted(startHonoServer);
|
||||
|
||||
expect(startHonoServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initialMcpConfig: expect.objectContaining({
|
||||
url: "https://example.com/mcp",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fills stdio cwd when omitted", async () => {
|
||||
void runWeb(["node", "run-web", "node", "server.js"]);
|
||||
await expectServerStarted(startHonoServer);
|
||||
|
||||
const config = startHonoServer.mock.calls[0]?.[0]?.initialMcpConfig;
|
||||
expect(config).toMatchObject({
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
args: ["server.js"],
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
});
|
||||
|
||||
it("warns when --header is passed", async () => {
|
||||
void runWeb([
|
||||
"node",
|
||||
"run-web",
|
||||
"https://example.com/mcp",
|
||||
"--transport",
|
||||
"http",
|
||||
"--header",
|
||||
"Authorization: Bearer x",
|
||||
]);
|
||||
await expectServerStarted(startHonoServer);
|
||||
|
||||
expect(
|
||||
warnLines.some((l) => l.includes("Warning: --header is accepted")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("loads a named server from a config file", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "run-web-test-"));
|
||||
const configPath = join(dir, "mcp.json");
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
api: {
|
||||
type: "streamable-http",
|
||||
url: "https://example.com/mcp",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
void runWeb([
|
||||
"node",
|
||||
"run-web",
|
||||
"--config",
|
||||
configPath,
|
||||
"--server",
|
||||
"api",
|
||||
]);
|
||||
await expectServerStarted(startHonoServer);
|
||||
|
||||
expect(startHonoServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initialMcpConfig: expect.objectContaining({
|
||||
type: "streamable-http",
|
||||
url: "https://example.com/mcp",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("exits when server config resolution fails", async () => {
|
||||
await expect(
|
||||
runWeb(["node", "run-web", "--config", "/no/such/file.json"]),
|
||||
).rejects.toThrow("process.exit:1");
|
||||
expect(errorLines.some((l) => l.startsWith("Error:"))).toBe(true);
|
||||
expect(startHonoServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exits when the web server fails to start", async () => {
|
||||
startHonoServer.mockRejectedValueOnce(new Error("bind failed"));
|
||||
|
||||
await expect(runWeb(["node", "run-web"])).rejects.toThrow("process.exit:1");
|
||||
expect(errorLines.some((l) => l.includes("bind failed"))).toBe(true);
|
||||
});
|
||||
|
||||
it("shuts down via SIGINT after a successful start", async () => {
|
||||
void runWeb(["node", "run-web"]);
|
||||
await expectServerStarted(startHonoServer);
|
||||
|
||||
const shutdown = signalHandlers.get("SIGINT");
|
||||
expect(shutdown).toBeDefined();
|
||||
expect(signalHandlers.get("SIGTERM")).toBeDefined();
|
||||
shutdown!();
|
||||
await vi.waitFor(() => expect(mockClose).toHaveBeenCalled());
|
||||
await vi.waitFor(() => expect(exitCode).toBe(0));
|
||||
});
|
||||
|
||||
it("preserves an existing stdio cwd", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "run-web-cwd-"));
|
||||
const configPath = join(dir, "mcp.json");
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
local: {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
args: ["server.js"],
|
||||
cwd: "/custom/cwd",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
void runWeb([
|
||||
"node",
|
||||
"run-web",
|
||||
"--config",
|
||||
configPath,
|
||||
"--server",
|
||||
"local",
|
||||
]);
|
||||
await expectServerStarted(startHonoServer);
|
||||
|
||||
expect(
|
||||
startHonoServer.mock.calls[0]?.[0]?.initialMcpConfig,
|
||||
).toMatchObject({
|
||||
cwd: "/custom/cwd",
|
||||
});
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves --server-url without a positional target", async () => {
|
||||
void runWeb([
|
||||
"node",
|
||||
"run-web",
|
||||
"--server-url",
|
||||
"https://example.com/mcp",
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
await expectServerStarted(startHonoServer);
|
||||
|
||||
expect(startHonoServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initialMcpConfig: expect.objectContaining({
|
||||
url: "https://example.com/mcp",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("exits when resolveServerConfigs returns no entries", async () => {
|
||||
vi.spyOn(nodeConfig, "resolveServerConfigs").mockReturnValueOnce([]);
|
||||
|
||||
await expect(
|
||||
runWeb([
|
||||
"node",
|
||||
"run-web",
|
||||
"https://example.com/mcp",
|
||||
"--transport",
|
||||
"http",
|
||||
]),
|
||||
).rejects.toThrow("process.exit:1");
|
||||
expect(
|
||||
errorLines.some((l) => l.includes("Could not resolve server config")),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
buildWebServerConfig,
|
||||
buildWebServerConfigFromEnv,
|
||||
printServerBanner,
|
||||
webServerConfigToInitialPayload,
|
||||
|
|
@ -192,6 +193,39 @@ describe("buildWebServerConfigFromEnv", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("buildWebServerConfig", () => {
|
||||
it("matches buildWebServerConfigFromEnv when initialMcpConfig is omitted", () => {
|
||||
process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] = "shared";
|
||||
expect(buildWebServerConfig()).toEqual(buildWebServerConfigFromEnv());
|
||||
expect(buildWebServerConfig({ initialMcpConfig: null })).toEqual(
|
||||
buildWebServerConfigFromEnv(),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a stdio initialMcpConfig while applying shared env defaults", () => {
|
||||
process.env.CLIENT_PORT = "7000";
|
||||
const initialMcpConfig = {
|
||||
type: "stdio" as const,
|
||||
command: "node",
|
||||
args: ["server.js"],
|
||||
cwd: "/srv",
|
||||
};
|
||||
const cfg = buildWebServerConfig({ initialMcpConfig });
|
||||
expect(cfg.port).toBe(7000);
|
||||
expect(cfg.initialMcpConfig).toEqual(initialMcpConfig);
|
||||
expect(cfg.allowedOrigins).toEqual(["http://localhost:7000"]);
|
||||
});
|
||||
|
||||
it("preserves remote transport initialMcpConfig", () => {
|
||||
const initialMcpConfig = {
|
||||
type: "streamable-http" as const,
|
||||
url: "https://example.com/mcp",
|
||||
};
|
||||
const cfg = buildWebServerConfig({ initialMcpConfig });
|
||||
expect(cfg.initialMcpConfig).toEqual(initialMcpConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe("webServerConfigToInitialPayload", () => {
|
||||
it("returns only defaultEnvironment when initialMcpConfig is null", () => {
|
||||
const payload = webServerConfigToInitialPayload(baseConfig());
|
||||
|
|
|
|||
|
|
@ -46,5 +46,5 @@
|
|||
"@napi-rs/keyring": ["./node_modules/@napi-rs/keyring"]
|
||||
}
|
||||
},
|
||||
"include": ["vite.config.ts", "server/**/*.ts"]
|
||||
"include": ["vite.config.ts", "tsup.runner.config.ts", "server/**/*.ts"]
|
||||
}
|
||||
|
|
|
|||
36
clients/web/tsup.runner.config.ts
Normal file
36
clients/web/tsup.runner.config.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { defineConfig } from 'tsup';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(dirname, '../..');
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
outDir: 'build',
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
target: 'node20',
|
||||
platform: 'node',
|
||||
noExternal: [/^@inspector\/core/],
|
||||
external: [
|
||||
'commander',
|
||||
'open',
|
||||
'pino',
|
||||
'hono',
|
||||
'@hono/node-server',
|
||||
'vite',
|
||||
'@vitejs/plugin-react',
|
||||
'zustand',
|
||||
'atomically',
|
||||
'chokidar',
|
||||
'@napi-rs/keyring',
|
||||
'@modelcontextprotocol/sdk',
|
||||
],
|
||||
esbuildOptions(options) {
|
||||
options.alias = {
|
||||
'@inspector/core': path.join(repoRoot, 'core'),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -10,70 +10,10 @@ import { playwright } from '@vitest/browser-playwright';
|
|||
import { honoMiddlewarePlugin } from './server/vite-hono-plugin';
|
||||
import { getViteBaseConfig } from './server/vite-base-config';
|
||||
import { buildWebServerConfigFromEnv } from './server/web-server-config';
|
||||
import { vitestSharedPaths } from '../../vitest.shared.mts';
|
||||
const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(dirname, '../..');
|
||||
|
||||
// Aliases shared between the top-level resolve and the vitest projects.
|
||||
// Vitest projects don't inherit `resolve` from the parent, so the unit and
|
||||
// integration projects redeclare them — keeping a single source here prevents
|
||||
// them from drifting (e.g. if a new core/* alias is added).
|
||||
const sharedAliases = {
|
||||
'@inspector/core': path.resolve(dirname, '../../core'),
|
||||
// Point at the BUILT test-servers entry, not src/, so that any test which
|
||||
// calls `getTestMcpServerPath()` (via `fileURLToPath(import.meta.url)`)
|
||||
// resolves to a `.js` path Node can spawn directly as a subprocess. The
|
||||
// integration tests build test-servers via `npm run test:integration`
|
||||
// (or `npm run test-servers:build`) before running.
|
||||
'@modelcontextprotocol/inspector-test-server': path.resolve(dirname, '../../test-servers/build/index.js'),
|
||||
};
|
||||
const sharedDedupe = [
|
||||
'react',
|
||||
'react-dom',
|
||||
// The SDK is installed under both clients/web/node_modules and the repo
|
||||
// root's node_modules (hoisted by npm). Without dedupe, source files in
|
||||
// core/ (no local node_modules) resolve to the root copy while test files
|
||||
// resolve to the clients/web copy — splitting class identity and breaking
|
||||
// vi.mock() / instanceof checks (see #1307).
|
||||
'@modelcontextprotocol/sdk',
|
||||
];
|
||||
|
||||
// Bare-module aliases needed when running tests from repoRoot (which has no
|
||||
// node_modules of its own). Shared between the unit and integration projects.
|
||||
// Use anchored regex `find` patterns so each package's own `exports` field
|
||||
// handles subpath resolution.
|
||||
const nodeModulesAliases = [
|
||||
{ find: /^react$/, replacement: path.resolve(dirname, 'node_modules/react') },
|
||||
{ find: /^pino$/, replacement: path.resolve(dirname, 'node_modules/pino') },
|
||||
{ find: /^pino\/browser\.js$/, replacement: path.resolve(dirname, 'node_modules/pino/browser.js') },
|
||||
{ find: /^zustand$/, replacement: path.resolve(dirname, 'node_modules/zustand') },
|
||||
{ find: /^zustand\/middleware$/, replacement: path.resolve(dirname, 'node_modules/zustand/middleware.js') },
|
||||
{ find: /^zustand\/vanilla$/, replacement: path.resolve(dirname, 'node_modules/zustand/vanilla.js') },
|
||||
{ find: /^hono$/, replacement: path.resolve(dirname, 'node_modules/hono/dist/index.js') },
|
||||
{ find: /^hono\/streaming$/, replacement: path.resolve(dirname, 'node_modules/hono/dist/helper/streaming/index.js') },
|
||||
{ find: /^@hono\/node-server$/, replacement: path.resolve(dirname, 'node_modules/@hono/node-server') },
|
||||
{ find: /^atomically$/, replacement: path.resolve(dirname, 'node_modules/atomically') },
|
||||
{ find: /^chokidar$/, replacement: path.resolve(dirname, 'node_modules/chokidar') },
|
||||
{ find: /^@napi-rs\/keyring$/, replacement: path.resolve(dirname, 'node_modules/@napi-rs/keyring') },
|
||||
{ find: /^express$/, replacement: path.resolve(dirname, 'node_modules/express') },
|
||||
{ find: /^yaml$/, replacement: path.resolve(dirname, 'node_modules/yaml') },
|
||||
// Pin the SDK auth subpath so test and source resolve to the exact same
|
||||
// module ID. Without this, the source's `import` from core/auth/*.ts and
|
||||
// the test's `vi.mock(...)` can resolve through different cache keys in
|
||||
// Vitest's transformer pipeline — the mock then fails to intercept the
|
||||
// source-side import (see #1307).
|
||||
{ find: /^@modelcontextprotocol\/sdk\/client\/auth\.js$/, replacement: path.resolve(dirname, 'node_modules/@modelcontextprotocol/sdk/dist/esm/client/auth.js') },
|
||||
];
|
||||
|
||||
// Project resolve config shared between the unit and integration projects.
|
||||
// sharedAliases come first as exact-match entries, then the bare-module
|
||||
// regex aliases that node-style imports from core/ rely on.
|
||||
const projectResolve = {
|
||||
alias: [
|
||||
...Object.entries(sharedAliases).map(([find, replacement]) => ({ find, replacement })),
|
||||
...nodeModulesAliases,
|
||||
],
|
||||
dedupe: sharedDedupe,
|
||||
};
|
||||
const { repoRoot, sharedDedupe, nodeModulesAliases, projectResolve, sharedAliases } =
|
||||
vitestSharedPaths(dirname);
|
||||
|
||||
// Integration tests live under clients/web/src/test/integration/ and run in
|
||||
// the node-env vitest project below. The folder is the manifest: anything
|
||||
|
|
|
|||
Loading…
Reference in a new issue