diff --git a/clients/cli/README.md b/clients/cli/README.md index fd3d8f87f..2e7be7630 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -98,6 +98,12 @@ Options that specify the MCP server (config file, ad-hoc command/URL, env vars, | `--metadata ` | General metadata (key=value); applied to all methods. | | `--tool-metadata ` | Tool-specific metadata for `tools/call`. | +### Logging (env) + +| Env var | Description | +| -------------- | --------------------------------------------------------------- | +| `MCP_LOG_FILE` | If set, CLI and InspectorClient logs are appended to this file. | + ## Why use the CLI? While the Web Client provides a rich visual interface, the CLI is designed for: diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 51014d0e6..d8995b648 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -18,6 +18,10 @@ import { parseKeyValuePair as parseEnvPair, parseHeaderPair, } from "@modelcontextprotocol/inspector-core/mcp/node/index.js"; +import { + createFileLogger, + silentLogger, +} from "@modelcontextprotocol/inspector-core/logging/node"; import type { JsonValue } from "@modelcontextprotocol/inspector-core/mcp/index.js"; import { LoggingLevelSchema, @@ -58,9 +62,21 @@ async function callMethod( const version = packageJson.version; const clientIdentity = { name, version }; + const logger = process.env.MCP_LOG_FILE + ? await createFileLogger({ + dest: process.env.MCP_LOG_FILE, + append: true, + mkdir: true, + level: "info", + name: "mcp-inspector-cli", + }) + : silentLogger; + logger.info("CLI starting"); + const inspectorClient = new InspectorClient(serverConfig, { environment: { transport: createTransportNode, + logger, }, clientIdentity, initialLoggingLevel: "debug", @@ -413,8 +429,12 @@ function parseArgs(argv?: string[]): { } export async function runCli(argv?: string[]): Promise { - const { serverConfig, methodArgs } = parseArgs(argv ?? process.argv); - await callMethod(serverConfig, methodArgs); + try { + const { serverConfig, methodArgs } = parseArgs(argv ?? process.argv); + await callMethod(serverConfig, methodArgs); + } catch (error) { + handleError(error); + } } export async function main(): Promise { diff --git a/clients/tui/README.md b/clients/tui/README.md index e050f9b46..9f6ec5975 100644 --- a/clients/tui/README.md +++ b/clients/tui/README.md @@ -39,6 +39,12 @@ When connecting to SSE or Streamable HTTP servers that use OAuth, you can pass: | `--client-metadata-url ` | OAuth Client ID Metadata Document URL (CIMD). | | `--callback-url ` | OAuth redirect/callback listener URL (default: `http://127.0.0.1:0/oauth/callback`). | +### Logging (env) + +| Env var | Description | +| -------------- | --------------------------------------------------------------- | +| `MCP_LOG_FILE` | If set, TUI and InspectorClient logs are appended to this file. | + ## Features The TUI provides terminal-native tabs and panes for interacting with your MCP server: diff --git a/clients/tui/src/logger.ts b/clients/tui/src/logger.ts index fe64650f6..1aff08d4f 100644 --- a/clients/tui/src/logger.ts +++ b/clients/tui/src/logger.ts @@ -1,23 +1,30 @@ -import path from "node:path"; -import pino from "pino"; +import type { Logger } from "pino"; +import { + silentLogger, + createFileLogger, +} from "@modelcontextprotocol/inspector-core/logging/node"; -const logDir = - process.env.MCP_INSPECTOR_LOG_DIR ?? - path.join( - process.env.HOME || process.env.USERPROFILE || ".", - ".mcp-inspector", - ); -const logPath = path.join(logDir, "auth.log"); +/** + * TUI logger (InspectorClient events, auth, etc.). + * File logger when MCP_LOG_FILE is set, else silentLogger. + */ +export let tuiLogger: Logger = silentLogger; /** - * TUI file logger for auth and InspectorClient events. - * Writes to ~/.mcp-inspector/auth.log so TUI console output is not corrupted. - * The app controls logger creation and configuration. + * If MCP_LOG_FILE is set, creates a file logger (awaits destination ready); + * otherwise uses silentLogger. Call at the start of runTui() before any work + * that might call process.exit(). */ -export const tuiLogger = pino( - { - name: "mcp-inspector-tui", - level: process.env.LOG_LEVEL ?? "info", - }, - pino.destination({ dest: logPath, append: true, mkdir: true }), -); +export async function initTuiLogger(): Promise { + if (process.env.MCP_LOG_FILE) { + tuiLogger = await createFileLogger({ + dest: process.env.MCP_LOG_FILE, + append: true, + mkdir: true, + level: "info", + name: "mcp-inspector-tui", + }); + } else { + tuiLogger = silentLogger; + } +} diff --git a/clients/tui/tui.tsx b/clients/tui/tui.tsx index 093c79dd7..ab01ab375 100755 --- a/clients/tui/tui.tsx +++ b/clients/tui/tui.tsx @@ -9,9 +9,13 @@ import { parseKeyValuePair, parseHeaderPair, } from "@modelcontextprotocol/inspector-core/mcp/node/index.js"; +import { initTuiLogger, tuiLogger } from "./src/logger.js"; import App from "./src/App.js"; export async function runTui(args?: string[]): Promise { + await initTuiLogger(); + tuiLogger.info("TUI starting"); + const program = new Command(); program diff --git a/clients/web/README.md b/clients/web/README.md index e30bc5001..564e2a3f1 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -72,7 +72,7 @@ All of these are set via **environment variables**; the web app has no command-l | **Sandbox port** (MCP Apps) | Env: `MCP_SANDBOX_PORT`; if unset, `SERVER_PORT` (legacy). Use `0` or leave unset for an automatic port. | automatic | | **Storage directory** (e.g. OAuth) | Env: `MCP_STORAGE_DIR` | (unset) | | **Allowed origins** (CORS) | Env: `ALLOWED_ORIGINS` (comma-separated) | client origin only | -| **Log file** | Env: `MCP_LOG_FILE` — if set, server logs are appended to this file. | (unset) | +| **Log file** | Env: `MCP_LOG_FILE` — if set, server and InspectorClient logs are appended to this file. | (unset) | | **Open browser on start** | Env: `MCP_AUTO_OPEN_ENABLED` — set to `false` to disable. | `true` | | **Development mode** | CLI only: `--dev` (Vite with HMR). No env var. | off | diff --git a/clients/web/src/components/AuthDebugger.tsx b/clients/web/src/components/AuthDebugger.tsx index ad164451a..7347568f2 100644 --- a/clients/web/src/components/AuthDebugger.tsx +++ b/clients/web/src/components/AuthDebugger.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { AlertCircle } from "lucide-react"; import type { InspectorClient } from "@modelcontextprotocol/inspector-core/mcp/index.js"; -import { silentLogger } from "@modelcontextprotocol/inspector-core/logging"; +import { silentLogger } from "@modelcontextprotocol/inspector-core/logging/browser"; import type { AuthGuidedState } from "@modelcontextprotocol/inspector-core/auth/types.js"; import type { WebEnvironmentResult } from "@/lib/adapters/environmentFactory"; import { OAuthFlowProgress } from "./OAuthFlowProgress"; diff --git a/clients/web/src/components/OAuthCallback.tsx b/clients/web/src/components/OAuthCallback.tsx index 0f2b19ce0..6594edb41 100644 --- a/clients/web/src/components/OAuthCallback.tsx +++ b/clients/web/src/components/OAuthCallback.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { InspectorClient } from "@modelcontextprotocol/inspector-core/mcp/index.js"; import type { WebEnvironmentResult } from "@/lib/adapters/environmentFactory"; import { parseOAuthState } from "@modelcontextprotocol/inspector-core/auth/index.js"; -import { silentLogger } from "@modelcontextprotocol/inspector-core/logging"; +import { silentLogger } from "@modelcontextprotocol/inspector-core/logging/browser"; import useTheme from "@/lib/hooks/useTheme"; import { useToast } from "@/lib/hooks/useToast"; import { diff --git a/clients/web/src/server.ts b/clients/web/src/server.ts index ab64dcc08..f19626ec8 100644 --- a/clients/web/src/server.ts +++ b/clients/web/src/server.ts @@ -34,6 +34,7 @@ export interface WebServerHandle { export async function startHonoServer( config: WebServerConfig, ): Promise { + config.logger.info("Web server starting"); const sandboxController = createSandboxController({ port: config.sandboxPort, host: config.sandboxHost, @@ -131,7 +132,7 @@ export async function startHonoServer( /** Run when this file is executed as the main module (e.g. node dist/server.js). */ async function runStandalone(): Promise { - const config = buildWebServerConfigFromEnv(); + const config = await buildWebServerConfigFromEnv(); const handle = await startHonoServer(config); const shutdown = () => { void handle.close().then(() => process.exit(0)); diff --git a/clients/web/src/vite-hono-plugin.ts b/clients/web/src/vite-hono-plugin.ts index 7081a0fda..ff141f994 100644 --- a/clients/web/src/vite-hono-plugin.ts +++ b/clients/web/src/vite-hono-plugin.ts @@ -15,15 +15,20 @@ import { } from "./web-server-config.js"; /** - * Plugin factory. Caller must pass a WebServerConfig (runner builds it from argv; vite.config builds it from env via buildWebServerConfigFromEnv). + * Plugin factory. Caller must pass a WebServerConfig or Promise + * (runner builds from argv; vite.config passes buildWebServerConfigFromEnv() which is async). */ -export function honoMiddlewarePlugin(config: WebServerConfig): Plugin { +export function honoMiddlewarePlugin( + config: WebServerConfig | Promise, +): Plugin { return { name: "hono-api-middleware", async configureServer(server) { + const resolvedConfig = await Promise.resolve(config); + resolvedConfig.logger.info("Web server starting (dev)"); const sandboxController = createSandboxController({ - port: config.sandboxPort, - host: config.sandboxHost, + port: resolvedConfig.sandboxPort, + host: resolvedConfig.sandboxHost, }); await sandboxController.start(); @@ -40,13 +45,15 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin { }; const { app: honoApp, authToken: resolvedToken } = createRemoteApp({ - authToken: config.dangerouslyOmitAuth ? undefined : config.authToken, - dangerouslyOmitAuth: config.dangerouslyOmitAuth, - storageDir: config.storageDir, - allowedOrigins: config.allowedOrigins, + authToken: resolvedConfig.dangerouslyOmitAuth + ? undefined + : resolvedConfig.authToken, + dangerouslyOmitAuth: resolvedConfig.dangerouslyOmitAuth, + storageDir: resolvedConfig.storageDir, + allowedOrigins: resolvedConfig.allowedOrigins, sandboxUrl: sandboxController.getUrl() ?? undefined, - logger: config.logger, - initialConfig: webServerConfigToInitialPayload(config), + logger: resolvedConfig.logger, + initialConfig: webServerConfigToInitialPayload(resolvedConfig), }); const sandboxUrl = sandboxController.getUrl(); @@ -56,16 +63,16 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin { const actualPort = typeof address === "object" && address !== null ? address.port - : config.port; + : resolvedConfig.port; const url = printServerBanner( - config, + resolvedConfig, actualPort, resolvedToken, sandboxUrl ?? undefined, ); - if (config.autoOpen) { + if (resolvedConfig.autoOpen) { open(url); } }; diff --git a/clients/web/src/web-server-config.ts b/clients/web/src/web-server-config.ts index e02a3aafe..3b8bb8669 100644 --- a/clients/web/src/web-server-config.ts +++ b/clients/web/src/web-server-config.ts @@ -2,9 +2,12 @@ * Config object for the web server (dev and prod). Passed in-process; no env handoff. */ -import pino from "pino"; import type { Logger } from "pino"; import type { MCPServerConfig } from "@modelcontextprotocol/inspector-core/mcp/types.js"; +import { + createFileLogger, + silentLogger, +} from "@modelcontextprotocol/inspector-core/logging/node"; import { API_SERVER_ENV_VARS, LEGACY_AUTH_TOKEN_ENV, @@ -24,7 +27,7 @@ export interface WebServerConfig { /** Sandbox port (0 = dynamic). */ sandboxPort: number; sandboxHost: string; - logger: Logger | undefined; + logger: Logger; /** When true, open browser after server starts. */ autoOpen: boolean; /** Root directory for static files (index.html, assets). When runner starts server in-process, pass path to dist/. */ @@ -148,8 +151,9 @@ export function printServerBanner( /** * Build WebServerConfig from process.env. Used when running server as standalone (e.g. node dist/server.js). + * When MCP_LOG_FILE is set, returns a Promise (file logger destination must be awaited). */ -export function buildWebServerConfigFromEnv(): WebServerConfig { +export async function buildWebServerConfigFromEnv(): Promise { const port = parseInt(process.env.CLIENT_PORT ?? "6274", 10); const hostname = process.env.HOST ?? "localhost"; const baseUrl = `http://${hostname}:${port}`; @@ -164,17 +168,15 @@ export function buildWebServerConfigFromEnv(): WebServerConfig { const sandboxPort = resolveSandboxPort(); - let logger: Logger | undefined; - if (process.env.MCP_LOG_FILE) { - logger = pino( - { level: "info" }, - pino.destination({ + const logger = process.env.MCP_LOG_FILE + ? await createFileLogger({ dest: process.env.MCP_LOG_FILE, append: true, mkdir: true, - }), - ); - } + level: "info", + name: "mcp-inspector-web", + }) + : silentLogger; return { port, diff --git a/clients/web/src/web.ts b/clients/web/src/web.ts index 5865d58a6..d5a73133f 100644 --- a/clients/web/src/web.ts +++ b/clients/web/src/web.ts @@ -2,6 +2,7 @@ import { resolve, join, dirname } from "path"; import { fileURLToPath } from "url"; import { randomBytes } from "crypto"; import { Command } from "commander"; +import type { Logger } from "pino"; import type { MCPServerConfig, StreamableHttpServerConfig, @@ -16,6 +17,10 @@ import { parseHeaderPair, type ServerConfigOptions, } from "@modelcontextprotocol/inspector-core/mcp/node/config.js"; +import { + createFileLogger, + silentLogger, +} from "@modelcontextprotocol/inspector-core/logging/node"; import { resolveSandboxPort } from "./sandbox-controller.js"; import type { WebServerConfig } from "./web-server-config.js"; import { startViteDevServer } from "./start-vite-dev-server.js"; @@ -93,6 +98,7 @@ function buildWebServerConfig( hostname: string, authToken: string, dangerouslyOmitAuth: boolean, + logger: Logger, ): WebServerConfig { const baseUrl = `http://${hostname}:${port}`; const initialMcpConfig: MCPServerConfig | null = @@ -136,7 +142,7 @@ function buildWebServerConfig( ], sandboxPort: resolveSandboxPort(), sandboxHost: hostname, - logger: undefined, + logger, autoOpen: process.env.MCP_AUTO_OPEN_ENABLED !== "false", }; } @@ -252,12 +258,23 @@ export async function runWeb(argv: string[]): Promise { (process.env[LEGACY_AUTH_TOKEN_ENV] as string | undefined) ?? randomBytes(32).toString("hex")); + const logger = process.env.MCP_LOG_FILE + ? await createFileLogger({ + dest: process.env.MCP_LOG_FILE, + append: true, + mkdir: true, + level: "info", + name: "mcp-inspector-web", + }) + : silentLogger; + const webConfig = buildWebServerConfig( clientOptions, port, hostname, authToken, dangerouslyOmitAuth, + logger, ); if (!clientOptions.isDev) { webConfig.staticRoot = join(__dirname, "..", "dist"); diff --git a/core/auth/index.ts b/core/auth/index.ts index 467b55a49..a4f1fc76d 100644 --- a/core/auth/index.ts +++ b/core/auth/index.ts @@ -40,8 +40,8 @@ export type { OAuthStateMode } from "./utils.js"; // Discovery export { discoverScopes } from "./discovery.js"; -// Logging (re-exported from core/logging) -export { silentLogger } from "../logging/index.js"; +// Logging (re-exported from core/logging/node) +export { silentLogger } from "../logging/node/index.js"; // State Machine export type { StateMachineContext, StateTransition } from "./state-machine.js"; export { oauthTransitions, OAuthStateMachine } from "./state-machine.js"; diff --git a/core/logging/index.ts b/core/logging/browser/index.ts similarity index 100% rename from core/logging/index.ts rename to core/logging/browser/index.ts diff --git a/core/logging/browser/logger.ts b/core/logging/browser/logger.ts new file mode 100644 index 000000000..c267a5ac0 --- /dev/null +++ b/core/logging/browser/logger.ts @@ -0,0 +1,10 @@ +/** + * Silent logger for browser contexts. Satisfies pino.Logger; does not output anything. + * Use when no logger is passed (e.g. OAuth callback with no client). Web components + * that need a fallback import from logging/browser so they don't pull in Node logging. + */ +// @ts-expect-error - pino/browser.js exists but TypeScript doesn't have types for the .js extension +import pino from "pino/browser.js"; +import type { Logger } from "pino"; + +export const silentLogger: Logger = pino({ level: "silent" }); diff --git a/core/logging/node/fileLogger.ts b/core/logging/node/fileLogger.ts new file mode 100644 index 000000000..fd3146a2f --- /dev/null +++ b/core/logging/node/fileLogger.ts @@ -0,0 +1,36 @@ +import pino from "pino"; +import type { Logger } from "pino"; + +export interface CreateFileLoggerOptions { + dest: string; + append?: boolean; + mkdir?: boolean; + level?: string; + name?: string; +} + +/** + * Creates a pino file logger whose destination is ready before use. + * Waits for the destination's `ready` event so exit handlers can flush safely. + * Use this instead of pino.destination({ dest }) + pino() when the process may exit early. + */ +export async function createFileLogger( + options: CreateFileLoggerOptions, +): Promise { + const dest = pino.destination({ + dest: options.dest, + append: options.append ?? true, + mkdir: options.mkdir ?? true, + }); + await new Promise((resolve, reject) => { + dest.once("ready", resolve); + dest.once("error", reject); + }); + return pino( + { + level: options.level ?? "info", + ...(options.name !== undefined && { name: options.name }), + }, + dest, + ); +} diff --git a/core/logging/node/index.ts b/core/logging/node/index.ts new file mode 100644 index 000000000..5725f0007 --- /dev/null +++ b/core/logging/node/index.ts @@ -0,0 +1,5 @@ +export { silentLogger } from "./logger.js"; +export { + createFileLogger, + type CreateFileLoggerOptions, +} from "./fileLogger.js"; diff --git a/core/logging/logger.ts b/core/logging/node/logger.ts similarity index 100% rename from core/logging/logger.ts rename to core/logging/node/logger.ts diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 26b4fb0a2..3c01d1602 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -95,7 +95,7 @@ import { ElicitationCreateMessage } from "./elicitationCreateMessage.js"; import type { AuthGuidedState, OAuthStep } from "../auth/types.js"; import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; import type pino from "pino"; -import { silentLogger } from "../logging/logger.js"; +import { silentLogger } from "../logging/node/logger.js"; import { createFetchTracker } from "./fetchTracking.js"; import { OAuthManager, type OAuthManagerConfig } from "./oauthManager.js"; diff --git a/core/package.json b/core/package.json index dcc5daaf0..210aa98e2 100644 --- a/core/package.json +++ b/core/package.json @@ -18,8 +18,10 @@ "./mcp/node/*": "./build/mcp/node/*", "./auth/browser": "./build/auth/browser/index.js", "./auth/browser/*": "./build/auth/browser/*", - "./logging": "./build/logging/index.js", - "./logging/*": "./build/logging/*", + "./logging/browser": "./build/logging/browser/index.js", + "./logging/browser/*": "./build/logging/browser/*", + "./logging/node": "./build/logging/node/index.js", + "./logging/node/*": "./build/logging/node/*", "./mcp/remote": "./build/mcp/remote/index.js", "./mcp/remote/*": "./build/mcp/remote/*", "./mcp/remote/node": "./build/mcp/remote/node/index.js",