diff --git a/client/src/App.tsx b/client/src/App.tsx index 5937d738..42b6fefc 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -141,6 +141,10 @@ const App = () => { return localStorage.getItem("lastOauthScope") || ""; }); + const [workingDir, setWorkingDir] = useState(() => { + return localStorage.getItem("lastWorkingDir") || ""; + }); + const [oauthClientSecret, setOauthClientSecret] = useState(() => { return localStorage.getItem("lastOauthClientSecret") || ""; }); @@ -264,6 +268,7 @@ const App = () => { args, sseUrl, env, + workingDir, customHeaders, oauthClientId, oauthClientSecret, @@ -399,6 +404,10 @@ const App = () => { localStorage.setItem("lastOauthScope", oauthScope); }, [oauthScope]); + useEffect(() => { + localStorage.setItem("lastWorkingDir", workingDir); + }, [workingDir]); + useEffect(() => { localStorage.setItem("lastOauthClientSecret", oauthClientSecret); }, [oauthClientSecret]); @@ -918,6 +927,8 @@ const App = () => { logLevel={logLevel} sendLogLevelRequest={sendLogLevelRequest} loggingSupported={!!serverCapabilities?.logging || false} + workingDir={workingDir} + setWorkingDir={setWorkingDir} connectionType={connectionType} setConnectionType={setConnectionType} /> diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index b369a967..fa6ac8ba 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -1,19 +1,19 @@ -import { useState, useCallback } from "react"; +import { useCallback, useState } from "react"; import { - Play, + Bug, + CheckCheck, ChevronDown, ChevronRight, CircleHelp, - Bug, - Github, + Copy, Eye, EyeOff, - RotateCcw, - Settings, + Github, HelpCircle, + Play, RefreshCwOff, - Copy, - CheckCheck, + RotateCcw, + Settings, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -34,12 +34,13 @@ import useTheme from "../lib/hooks/useTheme"; import { version } from "../../../package.json"; import { Tooltip, - TooltipTrigger, TooltipContent, + TooltipTrigger, } from "@/components/ui/tooltip"; import CustomHeaders from "./CustomHeaders"; import { CustomHeaders as CustomHeadersType } from "@/lib/types/customHeaders"; import { useToast } from "../lib/hooks/useToast"; +import { useWorkingDirValidation } from "@/lib/hooks/useWorkingDirValidation"; interface SidebarProps { connectionStatus: ConnectionStatus; @@ -69,6 +70,8 @@ interface SidebarProps { loggingSupported: boolean; config: InspectorConfig; setConfig: (config: InspectorConfig) => void; + workingDir: string; + setWorkingDir: (workingDir: string) => void; connectionType: "direct" | "proxy"; setConnectionType: (type: "direct" | "proxy") => void; } @@ -100,6 +103,8 @@ const Sidebar = ({ loggingSupported, config, setConfig, + workingDir, + setWorkingDir, connectionType, setConnectionType, }: SidebarProps) => { @@ -113,6 +118,15 @@ const Sidebar = ({ const [copiedServerFile, setCopiedServerFile] = useState(false); const { toast } = useToast(); + // Server-side validation on blur + const { + workingDirError, + setWorkingDirError, + validateOnBlur, + validateNow, + isValidating, + } = useWorkingDirValidation(workingDir, config); + const connectionTypeTip = "Connect to server directly (requires CORS config on server) or via MCP Inspector Proxy"; // Reusable error reporter for copy actions @@ -130,11 +144,15 @@ const Sidebar = ({ // Shared utility function to generate server config const generateServerConfig = useCallback(() => { if (transportType === "stdio") { - return { + const config = { command, args: args.trim() ? args.split(/\s+/) : [], env: { ...env }, }; + if (workingDir) { + return { ...config, workingDir: workingDir }; + } + return config; } if (transportType === "sse") { return { @@ -151,7 +169,7 @@ const Sidebar = ({ }; } return {}; - }, [transportType, command, args, env, sseUrl]); + }, [transportType, command, args, env, sseUrl, workingDir]); // Memoized config entry generator const generateMCPServerEntry = useCallback(() => { @@ -294,6 +312,31 @@ const Sidebar = ({ className="font-mono" /> +
+ + { + setWorkingDir(e.target.value); + // Clear prior validation error while editing; will re-validate on blur + if (workingDirError) setWorkingDirError(null); + }} + onBlur={validateOnBlur} + className="font-mono" + /> + {workingDirError && ( +
+ {workingDirError} +
+ )} +
) : ( <> @@ -734,7 +777,25 @@ const Sidebar = ({ )} {connectionStatus !== "connected" && ( - diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index 03e898ca..4ebdf882 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -61,6 +61,8 @@ describe("Sidebar", () => { loggingSupported: true, config: DEFAULT_INSPECTOR_CONFIG, setConfig: jest.fn(), + workingDir: "", + setWorkingDir: jest.fn(), connectionType: "proxy" as const, setConnectionType: jest.fn(), }; @@ -76,6 +78,9 @@ describe("Sidebar", () => { beforeEach(() => { jest.clearAllMocks(); jest.clearAllTimers(); + // Reset fetch mock per test + // @ts-expect-error - assignable in test env + global.fetch = undefined; }); describe("Command and arguments", () => { @@ -110,6 +115,97 @@ describe("Sidebar", () => { }); }); + describe("Working Directory", () => { + it("should display working directory input field for stdio transport", () => { + renderSidebar({ transportType: "stdio" }); + + const workingDirInput = screen.getByLabelText( + "Working Directory (optional)", + ); + expect(workingDirInput).toBeInTheDocument(); + expect(workingDirInput).toHaveAttribute( + "placeholder", + "Working Directory (optional)", + ); + }); + + it("should not display working directory input field for non-stdio transports", () => { + renderSidebar({ transportType: "sse" }); + + const workingDirInput = screen.queryByLabelText( + "Working Directory (optional)", + ); + expect(workingDirInput).not.toBeInTheDocument(); + }); + + it("should update working directory value when input changes", () => { + const setWorkingDir = jest.fn(); + renderSidebar({ + transportType: "stdio", + workingDir: "/path/to/project", + setWorkingDir, + }); + + const workingDirInput = screen.getByLabelText( + "Working Directory (optional)", + ); + fireEvent.change(workingDirInput, { target: { value: "/new/path" } }); + + expect(setWorkingDir).toHaveBeenCalledWith("/new/path"); + }); + + it("should show validation error for invalid working directory path on blur", async () => { + // Mock fetch to return invalid + // @ts-expect-error - jasmine env + global.fetch = jest.fn(() => + Promise.resolve({ + json: () => + Promise.resolve({ + valid: false, + error: "Directory does not exist or is not accessible", + }), + }), + ); + + renderSidebar({ + transportType: "stdio", + workingDir: "/nonexistent/path", + }); + const workingDirInput = screen.getByLabelText( + "Working Directory (optional)", + ); + await act(async () => { + fireEvent.blur(workingDirInput); + }); + + expect( + await screen.findByText( + "Directory does not exist or is not accessible", + ), + ).toBeInTheDocument(); + }); + + it("should not show validation error for valid working directory path on blur", async () => { + // Mock fetch to return valid + // @ts-expect-error - jasmine env + global.fetch = jest.fn(() => + Promise.resolve({ json: () => Promise.resolve({ valid: true }) }), + ); + + renderSidebar({ transportType: "stdio", workingDir: "/tmp" }); + const workingDirInput = screen.getByLabelText( + "Working Directory (optional)", + ); + await act(async () => { + fireEvent.blur(workingDirInput); + }); + + expect( + screen.queryByText("Directory does not exist or is not accessible"), + ).not.toBeInTheDocument(); + }); + }); + describe("Environment Variables", () => { const openEnvVarsSection = () => { const button = screen.getByTestId("env-vars-button"); @@ -457,6 +553,40 @@ describe("Sidebar", () => { }); }); + it("should copy server entry configuration with working directory for STDIO transport", async () => { + const command = "node"; + const args = "--inspect server.js"; + const env = { API_KEY: "test-key", DEBUG: "true" }; + const workingDir = "/path/to/project"; + + renderSidebar({ + transportType: "stdio", + command, + args, + env, + workingDir, + }); + + await act(async () => { + const { serverEntry } = getCopyButtons(); + fireEvent.click(serverEntry); + jest.runAllTimers(); + }); + + expect(mockClipboardWrite).toHaveBeenCalledTimes(1); + const expectedConfig = JSON.stringify( + { + command, + args: ["--inspect", "server.js"], + env, + workingDir, + }, + null, + 4, + ); + expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig); + }); + it("should copy servers file configuration to clipboard for STDIO transport", async () => { const command = "node"; const args = "--inspect server.js"; @@ -492,6 +622,44 @@ describe("Sidebar", () => { expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig); }); + it("should copy servers file configuration with working directory for STDIO transport", async () => { + const command = "node"; + const args = "--inspect server.js"; + const env = { API_KEY: "test-key", DEBUG: "true" }; + const workingDir = "/path/to/project"; + + renderSidebar({ + transportType: "stdio", + command, + args, + env, + workingDir, + }); + + await act(async () => { + const { serversFile } = getCopyButtons(); + fireEvent.click(serversFile); + jest.runAllTimers(); + }); + + expect(mockClipboardWrite).toHaveBeenCalledTimes(1); + const expectedConfig = JSON.stringify( + { + mcpServers: { + "default-server": { + command, + args: ["--inspect", "server.js"], + env, + workingDir, + }, + }, + }, + null, + 4, + ); + expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig); + }); + it("should copy server entry configuration to clipboard for SSE transport", async () => { const sseUrl = "http://localhost:3000/events"; renderSidebar({ transportType: "sse", sseUrl }); diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index a9ba6825..499c6ada 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -127,6 +127,7 @@ describe("useConnection", () => { args: "", sseUrl: "http://localhost:8080", env: {}, + workingDir: "", config: DEFAULT_INSPECTOR_CONFIG, }; @@ -1258,6 +1259,52 @@ describe("useConnection", () => { ).toBeNull(); }); + test("sends working directory query parameter for stdio transport when provided", async () => { + const propsWithWorkingDir = { + ...defaultProps, + transportType: "stdio" as const, + command: "test-command", + args: "test-args", + env: {}, + workingDir: "/path/to/project", + config: DEFAULT_INSPECTOR_CONFIG, + }; + + const { result } = renderHook(() => useConnection(propsWithWorkingDir)); + + await act(async () => { + await result.current.connect(); + }); + + // Check that the URL contains the working directory parameter + expect(mockSSETransport.url?.searchParams.get("workingDir")).toBe( + "/path/to/project", + ); + }); + + test("does not send working directory parameter when not provided", async () => { + const propsWithoutWorkingDir = { + ...defaultProps, + transportType: "stdio" as const, + command: "test-command", + args: "test-args", + env: {}, + workingDir: "", + config: DEFAULT_INSPECTOR_CONFIG, + }; + + const { result } = renderHook(() => + useConnection(propsWithoutWorkingDir), + ); + + await act(async () => { + await result.current.connect(); + }); + + // Check that the URL does not contain the working directory parameter + expect(mockSSETransport.url?.searchParams.get("workingDir")).toBeNull(); + }); + test("does not send proxyFullAddress parameter for streamable-http transport", async () => { const propsWithStreamableHttp = { ...defaultProps, diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 8639feeb..4cbea6b1 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -64,6 +64,7 @@ interface UseConnectionOptions { args: string; sseUrl: string; env: Record; + workingDir?: string; // Custom headers support customHeaders?: CustomHeaders; oauthClientId?: string; @@ -88,6 +89,7 @@ export function useConnection({ args, sseUrl, env, + workingDir, customHeaders, oauthClientId, oauthClientSecret, @@ -560,6 +562,9 @@ export function useConnection({ mcpProxyServerUrl.searchParams.append("command", command); mcpProxyServerUrl.searchParams.append("args", args); mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env)); + if (workingDir) { + mcpProxyServerUrl.searchParams.append("workingDir", workingDir); + } const proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS .value as string; diff --git a/client/src/lib/hooks/useWorkingDirValidation.ts b/client/src/lib/hooks/useWorkingDirValidation.ts new file mode 100644 index 00000000..230c2514 --- /dev/null +++ b/client/src/lib/hooks/useWorkingDirValidation.ts @@ -0,0 +1,51 @@ +import { useCallback, useState } from "react"; +import { InspectorConfig } from "@/lib/configurationTypes"; +import { getMCPProxyAddress, getMCPProxyAuthToken } from "@/utils/configUtils"; + +export function useWorkingDirValidation( + workingDir: string, + config: InspectorConfig, +) { + const [workingDirError, setWorkingDirError] = useState(null); + const [isValidating, setIsValidating] = useState(false); + + const validateNow = useCallback(async (): Promise => { + if (!workingDir) { + setWorkingDirError(null); + return true; + } + setIsValidating(true); + try { + const base = getMCPProxyAddress(config); + const url = new URL(`${base}/validate/working-dir`); + url.searchParams.set("path", workingDir); + + const { token, header } = getMCPProxyAuthToken(config); + const resp = await fetch(url.toString(), { + headers: token ? { [header]: `Bearer ${token}` } : undefined, + }); + const data: { valid: boolean; error?: string } = await resp.json(); + setWorkingDirError( + data.valid ? null : data.error || "Invalid working directory", + ); + return !!data.valid; + } catch { + setWorkingDirError("Unable to validate working directory"); + return false; + } finally { + setIsValidating(false); + } + }, [config, workingDir]); + + const validateOnBlur = useCallback(async () => { + await validateNow(); + }, [validateNow]); + + return { + workingDirError, + setWorkingDirError, + validateOnBlur, + validateNow, + isValidating, + } as const; +} diff --git a/server/src/__tests__/validationUtils.spec.ts b/server/src/__tests__/validationUtils.spec.ts new file mode 100644 index 00000000..d517644f --- /dev/null +++ b/server/src/__tests__/validationUtils.spec.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "@jest/globals"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { validateWorkingDirectoryAbsolute } from "../validationUtils.js"; + +describe("validateWorkingDirectoryAbsolute", () => { + it("returns error for missing path", async () => { + const res = await validateWorkingDirectoryAbsolute(""); + expect(res.valid).toBe(false); + expect(res.error).toBe("Missing path"); + }); + + it("returns error for non-absolute path", async () => { + const res = await validateWorkingDirectoryAbsolute("./rel"); + expect(res.valid).toBe(false); + expect(res.error).toBe("Path must be absolute"); + }); + + it("returns valid for existing readable directory", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "mcpv-")); + try { + const res = await validateWorkingDirectoryAbsolute(dir); + expect(res.valid).toBe(true); + expect(res.error).toBeUndefined(); + } finally { + await fs.promises.rm(dir, { recursive: true, force: true }); + } + }); + + it("returns error when path does not exist", async () => { + const nonExistent = path.join(os.tmpdir(), `nope-${Date.now()}`); + const res = await validateWorkingDirectoryAbsolute(nonExistent); + expect(res.valid).toBe(false); + expect(res.error).toContain("Directory does not exist"); + }); + + it("returns error when path is a file", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "mcpf-")); + const file = path.join(dir, "f.txt"); + await fs.promises.writeFile(file, "x"); + try { + const res = await validateWorkingDirectoryAbsolute(file); + expect(res.valid).toBe(false); + expect(res.error).toBe("Not a directory"); + } finally { + await fs.promises.rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/server/src/index.ts b/server/src/index.ts index 88954ebc..f95f90fd 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -25,6 +25,9 @@ import express from "express"; import { findActualExecutable } from "spawn-rx"; import mcpProxy from "./mcpProxy.js"; import { randomUUID, randomBytes, timingSafeEqual } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { validateWorkingDirectoryAbsolute } from "./validationUtils.js"; const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277"; @@ -337,17 +340,26 @@ const createTransport = async ( const origArgs = shellParseArgs(query.args as string) as string[]; const queryEnv = query.env ? JSON.parse(query.env as string) : {}; const env = { ...defaultEnvironment, ...process.env, ...queryEnv }; + const workingDir = query.workingDir as string; const { cmd, args } = findActualExecutable(command, origArgs); - console.log(`STDIO transport: command=${cmd}, args=${args}`); + console.log( + `STDIO transport: command=${cmd}, args=${args}, workingDir=${workingDir}`, + ); - const transport = new StdioClientTransport({ + const transportOptions: any = { command: cmd, args, env, stderr: "pipe", - }); + }; + + if (workingDir) { + transportOptions.cwd = workingDir; + } + + const transport = new StdioClientTransport(transportOptions); await transport.start(); return { transport }; @@ -703,6 +715,24 @@ app.get( }, ); +// Working directory validation endpoint +app.get( + "/validate/working-dir", + originValidationMiddleware, + authMiddleware, + async (req, res) => { + try { + const p = String(req.query.path || ""); + const result = await validateWorkingDirectoryAbsolute(p); + const status = p ? 200 : 400; + res.status(status).json(result); + } catch (error) { + console.error("Error in /validate/working-dir route:", error); + res.status(500).json({ valid: false, error: "Internal server error" }); + } + }, +); + app.post( "/message", originValidationMiddleware, diff --git a/server/src/validationUtils.ts b/server/src/validationUtils.ts new file mode 100644 index 00000000..b3775339 --- /dev/null +++ b/server/src/validationUtils.ts @@ -0,0 +1,31 @@ +import fs from "node:fs"; +import path from "node:path"; + +export type WorkingDirValidationResult = { + valid: boolean; + error?: string; +}; + +export async function validateWorkingDirectoryAbsolute( + directoryPath: string, +): Promise { + if (!directoryPath) { + return { valid: false, error: "Missing path" }; + } + if (!path.isAbsolute(directoryPath)) { + return { valid: false, error: "Path must be absolute" }; + } + try { + const stat = await fs.promises.stat(directoryPath); + if (!stat.isDirectory()) { + return { valid: false, error: "Not a directory" }; + } + await fs.promises.access(directoryPath, fs.constants.R_OK); + return { valid: true }; + } catch { + return { + valid: false, + error: "Directory does not exist or is not accessible", + }; + } +}