Compare commits

..

2 Commits

Author SHA1 Message Date
Devin AI
bb6ab5a85a style: Format test files with Prettier
Co-Authored-By: ashwin@anthropic.com <ashwin@anthropic.com>
2025-01-16 19:27:49 +00:00
Devin AI
ce7f65b5be Add frontend unit tests
- Set up Vitest with React Testing Library
- Add comprehensive tests for Button and ListPane components
- Configure TypeScript for test environment
- Add test type declarations

Co-Authored-By: ashwin@anthropic.com <ashwin@anthropic.com>
2025-01-16 19:24:51 +00:00
50 changed files with 489 additions and 7061 deletions

View File

@@ -25,11 +25,6 @@ jobs:
# Working around https://github.com/npm/cli/issues/4828 # Working around https://github.com/npm/cli/issues/4828
# - run: npm ci # - run: npm ci
- run: npm install --no-package-lock - run: npm install --no-package-lock
- name: Run client tests
working-directory: ./client
run: npm test
- run: npm run build - run: npm run build
publish: publish:

1
.gitignore vendored
View File

@@ -4,4 +4,3 @@ server/build
client/dist client/dist
client/tsconfig.app.tsbuildinfo client/tsconfig.app.tsbuildinfo
client/tsconfig.node.tsbuildinfo client/tsconfig.node.tsbuildinfo
.vscode

View File

@@ -1,33 +0,0 @@
# MCP Inspector Development Guide
## Build Commands
- Build all: `npm run build`
- Build client: `npm run build-client`
- Build server: `npm run build-server`
- Development mode: `npm run dev` (use `npm run dev:windows` on Windows)
- Format code: `npm run prettier-fix`
- Client lint: `cd client && npm run lint`
## Code Style Guidelines
- Use TypeScript with proper type annotations
- Follow React functional component patterns with hooks
- Use ES modules (import/export) not CommonJS
- Use Prettier for formatting (auto-formatted on commit)
- Follow existing naming conventions:
- camelCase for variables and functions
- PascalCase for component names and types
- kebab-case for file names
- Use async/await for asynchronous operations
- Implement proper error handling with try/catch blocks
- Use Tailwind CSS for styling in the client
- Keep components small and focused on a single responsibility
## Project Organization
The project is organized as a monorepo with workspaces:
- `client/`: React frontend with Vite, TypeScript and Tailwind
- `server/`: Express backend with TypeScript
- `bin/`: CLI scripts

View File

@@ -11,7 +11,7 @@ The MCP inspector is a developer tool for testing and debugging MCP servers.
To inspect an MCP server implementation, there's no need to clone this repo. Instead, use `npx`. For example, if your server is built at `build/index.js`: To inspect an MCP server implementation, there's no need to clone this repo. Instead, use `npx`. For example, if your server is built at `build/index.js`:
```bash ```bash
npx @modelcontextprotocol/inspector node build/index.js npx @modelcontextprotocol/inspector build/index.js
``` ```
You can pass both arguments and environment variables to your MCP server. Arguments are passed directly to your server, while environment variables can be set using the `-e` flag: You can pass both arguments and environment variables to your MCP server. Arguments are passed directly to your server, while environment variables can be set using the `-e` flag:
@@ -21,27 +21,23 @@ You can pass both arguments and environment variables to your MCP server. Argume
npx @modelcontextprotocol/inspector build/index.js arg1 arg2 npx @modelcontextprotocol/inspector build/index.js arg1 arg2
# Pass environment variables only # Pass environment variables only
npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/index.js npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 build/index.js
# Pass both environment variables and arguments # Pass both environment variables and arguments
npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/index.js arg1 arg2 npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 build/index.js arg1 arg2
# Use -- to separate inspector flags from server arguments # Use -- to separate inspector flags from server arguments
npx @modelcontextprotocol/inspector -e KEY=$VALUE -- node build/index.js -e server-flag npx @modelcontextprotocol/inspector -e KEY=$VALUE -- build/index.js -e server-flag
``` ```
The inspector runs both a client UI (default port 5173) and an MCP proxy server (default port 3000). Open the client UI in your browser to use the inspector. You can customize the ports if needed: The inspector runs both a client UI (default port 5173) and an MCP proxy server (default port 3000). Open the client UI in your browser to use the inspector. You can customize the ports if needed:
```bash ```bash
CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node build/index.js CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector build/index.js
``` ```
For more details on ways to use the inspector, see the [Inspector section of the MCP docs site](https://modelcontextprotocol.io/docs/tools/inspector). For help with debugging, see the [Debugging guide](https://modelcontextprotocol.io/docs/tools/debugging). For more details on ways to use the inspector, see the [Inspector section of the MCP docs site](https://modelcontextprotocol.io/docs/tools/inspector). For help with debugging, see the [Debugging guide](https://modelcontextprotocol.io/docs/tools/debugging).
### Authentication
The inspector supports bearer token authentication for SSE connections. Enter your token in the UI when connecting to an MCP server, and it will be sent in the Authorization header.
### From this repository ### From this repository
If you're working on the inspector itself: If you're working on the inspector itself:
@@ -52,13 +48,6 @@ Development mode:
npm run dev npm run dev
``` ```
> **Note for Windows users:**
> On Windows, use the following command instead:
>
> ```bash
> npm run dev:windows
> ```
Production mode: Production mode:
```bash ```bash

View File

@@ -27,15 +27,9 @@ async function main() {
} }
if (parsingFlags && arg === "-e" && i + 1 < args.length) { if (parsingFlags && arg === "-e" && i + 1 < args.length) {
const envVar = args[++i]; const [key, value] = args[++i].split("=");
const equalsIndex = envVar.indexOf("="); if (key && value) {
if (equalsIndex !== -1) {
const key = envVar.substring(0, equalsIndex);
const value = envVar.substring(equalsIndex + 1);
envVars[key] = value; envVars[key] = value;
} else {
envVars[envVar] = "";
} }
} else if (!command) { } else if (!command) {
command = arg; command = arg;

View File

@@ -9,10 +9,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const distPath = join(__dirname, "../dist"); const distPath = join(__dirname, "../dist");
const server = http.createServer((request, response) => { const server = http.createServer((request, response) => {
return handler(request, response, { return handler(request, response, { public: distPath });
public: distPath,
rewrites: [{ source: "/**", destination: "/index.html" }],
});
}); });
const port = process.env.PORT || 5173; const port = process.env.PORT || 5173;

View File

@@ -1,37 +0,0 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "jsdom",
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
"^../components/DynamicJsonForm$":
"<rootDir>/src/utils/__mocks__/DynamicJsonForm.ts",
"^../../components/DynamicJsonForm$":
"<rootDir>/src/utils/__mocks__/DynamicJsonForm.ts",
},
transform: {
"^.+\\.tsx?$": [
"ts-jest",
{
useESM: true,
jsx: "react-jsx",
tsconfig: "tsconfig.jest.json",
},
],
},
extensionsToTreatAsEsm: [".ts", ".tsx"],
testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
// Exclude directories and files that don't need to be tested
testPathIgnorePatterns: [
"/node_modules/",
"/dist/",
"/bin/",
"\\.config\\.(js|ts|cjs|mjs)$",
],
// Exclude the same patterns from coverage reports
coveragePathIgnorePatterns: [
"/node_modules/",
"/dist/",
"/bin/",
"\\.config\\.(js|ts|cjs|mjs)$",
],
};

View File

@@ -1,6 +1,6 @@
{ {
"name": "@modelcontextprotocol/inspector-client", "name": "@modelcontextprotocol/inspector-client",
"version": "0.7.0", "version": "0.3.0",
"description": "Client-side application for the Model Context Protocol inspector", "description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT", "license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)", "author": "Anthropic, PBC (https://anthropic.com)",
@@ -19,29 +19,20 @@
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"test": "jest --config jest.config.cjs", "test": "vitest run"
"test:watch": "jest --config jest.config.cjs --watch"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1", "@modelcontextprotocol/sdk": "^1.0.3",
"@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.3",
"@radix-ui/react-select": "^2.1.2", "@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1",
"@types/prismjs": "^1.26.5",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.4",
"lucide-react": "^0.447.0", "lucide-react": "^0.447.0",
"pkce-challenge": "^4.1.0",
"prismjs": "^1.29.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-simple-code-editor": "^0.14.1",
"react-toastify": "^10.0.6", "react-toastify": "^10.0.6",
"serve-handler": "^6.1.6", "serve-handler": "^6.1.6",
"tailwind-merge": "^2.5.3", "tailwind-merge": "^2.5.3",
@@ -50,25 +41,24 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.11.1", "@eslint/js": "^9.11.1",
"@types/jest": "^29.5.14", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/node": "^22.7.5", "@types/node": "^22.7.5",
"@types/react": "^18.3.10", "@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/serve-handler": "^6.1.4", "@types/serve-handler": "^6.1.4",
"@vitejs/plugin-react": "^4.3.2", "@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"co": "^4.6.0",
"eslint": "^9.11.1", "eslint": "^9.11.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.12", "eslint-plugin-react-refresh": "^0.4.12",
"globals": "^15.9.0", "globals": "^15.9.0",
"jest": "^29.7.0", "jsdom": "^26.0.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"tailwindcss": "^3.4.13", "tailwindcss": "^3.4.13",
"ts-jest": "^29.2.6",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"typescript-eslint": "^8.7.0", "typescript-eslint": "^8.7.0",
"vite": "^5.4.8" "vite": "^5.4.8",
"vitest": "^3.0.0"
} }
} }

View File

@@ -1,3 +1,5 @@
import { useDraggablePane } from "./lib/hooks/useDraggablePane";
import { useConnection } from "./lib/hooks/useConnection";
import { import {
ClientRequest, ClientRequest,
CompatibilityCallToolResult, CompatibilityCallToolResult,
@@ -8,18 +10,15 @@ import {
ListPromptsResultSchema, ListPromptsResultSchema,
ListResourcesResultSchema, ListResourcesResultSchema,
ListResourceTemplatesResultSchema, ListResourceTemplatesResultSchema,
ListToolsResultSchema,
ReadResourceResultSchema, ReadResourceResultSchema,
ListToolsResultSchema,
Resource, Resource,
ResourceTemplate, ResourceTemplate,
Root, Root,
ServerNotification, ServerNotification,
Tool, Tool,
LoggingLevel,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import React, { Suspense, useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useConnection } from "./lib/hooks/useConnection";
import { useDraggablePane } from "./lib/hooks/useDraggablePane";
import { StdErrNotification } from "./lib/notificationTypes"; import { StdErrNotification } from "./lib/notificationTypes";
@@ -33,7 +32,6 @@ import {
MessageSquare, MessageSquare,
} from "lucide-react"; } from "lucide-react";
import { toast } from "react-toastify";
import { z } from "zod"; import { z } from "zod";
import "./App.css"; import "./App.css";
import ConsoleTab from "./components/ConsoleTab"; import ConsoleTab from "./components/ConsoleTab";
@@ -48,20 +46,9 @@ import ToolsTab from "./components/ToolsTab";
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const PROXY_PORT = params.get("proxyPort") ?? "3000"; const PROXY_PORT = params.get("proxyPort") ?? "3000";
const PROXY_SERVER_URL = `http://${window.location.hostname}:${PROXY_PORT}`; const PROXY_SERVER_URL = `http://localhost:${PROXY_PORT}`;
const App = () => { const App = () => {
// Handle OAuth callback route
if (window.location.pathname === "/oauth/callback") {
const OAuthCallback = React.lazy(
() => import("./components/OAuthCallback"),
);
return (
<Suspense fallback={<div>Loading...</div>}>
<OAuthCallback />
</Suspense>
);
}
const [resources, setResources] = useState<Resource[]>([]); const [resources, setResources] = useState<Resource[]>([]);
const [resourceTemplates, setResourceTemplates] = useState< const [resourceTemplates, setResourceTemplates] = useState<
ResourceTemplate[] ResourceTemplate[]
@@ -84,24 +71,14 @@ const App = () => {
return localStorage.getItem("lastArgs") || ""; return localStorage.getItem("lastArgs") || "";
}); });
const [sseUrl, setSseUrl] = useState<string>(() => { const [sseUrl, setSseUrl] = useState<string>("http://localhost:3001/sse");
return localStorage.getItem("lastSseUrl") || "http://localhost:3001/sse"; const [transportType, setTransportType] = useState<"stdio" | "sse">("stdio");
});
const [transportType, setTransportType] = useState<"stdio" | "sse">(() => {
return (
(localStorage.getItem("lastTransportType") as "stdio" | "sse") || "stdio"
);
});
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
const [notifications, setNotifications] = useState<ServerNotification[]>([]); const [notifications, setNotifications] = useState<ServerNotification[]>([]);
const [stdErrNotifications, setStdErrNotifications] = useState< const [stdErrNotifications, setStdErrNotifications] = useState<
StdErrNotification[] StdErrNotification[]
>([]); >([]);
const [roots, setRoots] = useState<Root[]>([]); const [roots, setRoots] = useState<Root[]>([]);
const [env, setEnv] = useState<Record<string, string>>({}); const [env, setEnv] = useState<Record<string, string>>({});
const [bearerToken, setBearerToken] = useState<string>(() => {
return localStorage.getItem("lastBearerToken") || "";
});
const [pendingSampleRequests, setPendingSampleRequests] = useState< const [pendingSampleRequests, setPendingSampleRequests] = useState<
Array< Array<
@@ -133,10 +110,6 @@ const App = () => {
const [selectedResource, setSelectedResource] = useState<Resource | null>( const [selectedResource, setSelectedResource] = useState<Resource | null>(
null, null,
); );
const [resourceSubscriptions, setResourceSubscriptions] = useState<
Set<string>
>(new Set<string>());
const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null); const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
const [selectedTool, setSelectedTool] = useState<Tool | null>(null); const [selectedTool, setSelectedTool] = useState<Tool | null>(null);
const [nextResourceCursor, setNextResourceCursor] = useState< const [nextResourceCursor, setNextResourceCursor] = useState<
@@ -160,8 +133,6 @@ const App = () => {
requestHistory, requestHistory,
makeRequest: makeConnectionRequest, makeRequest: makeConnectionRequest,
sendNotification, sendNotification,
handleCompletion,
completionsSupported,
connect: connectMcpServer, connect: connectMcpServer,
} = useConnection({ } = useConnection({
transportType, transportType,
@@ -169,7 +140,6 @@ const App = () => {
args, args,
sseUrl, sseUrl,
env, env,
bearerToken,
proxyServerUrl: PROXY_SERVER_URL, proxyServerUrl: PROXY_SERVER_URL,
onNotification: (notification) => { onNotification: (notification) => {
setNotifications((prev) => [...prev, notification as ServerNotification]); setNotifications((prev) => [...prev, notification as ServerNotification]);
@@ -189,6 +159,29 @@ const App = () => {
getRoots: () => rootsRef.current, getRoots: () => rootsRef.current,
}); });
const makeRequest = async <T extends z.ZodType>(
request: ClientRequest,
schema: T,
tabKey?: keyof typeof errors,
) => {
try {
const response = await makeConnectionRequest(request, schema);
if (tabKey !== undefined) {
clearError(tabKey);
}
return response;
} catch (e) {
const errorString = (e as Error).message ?? String(e);
if (tabKey !== undefined) {
setErrors((prev) => ({
...prev,
[tabKey]: errorString,
}));
}
throw e;
}
};
useEffect(() => { useEffect(() => {
localStorage.setItem("lastCommand", command); localStorage.setItem("lastCommand", command);
}, [command]); }, [command]);
@@ -197,35 +190,6 @@ const App = () => {
localStorage.setItem("lastArgs", args); localStorage.setItem("lastArgs", args);
}, [args]); }, [args]);
useEffect(() => {
localStorage.setItem("lastSseUrl", sseUrl);
}, [sseUrl]);
useEffect(() => {
localStorage.setItem("lastTransportType", transportType);
}, [transportType]);
useEffect(() => {
localStorage.setItem("lastBearerToken", bearerToken);
}, [bearerToken]);
// Auto-connect if serverUrl is provided in URL params (e.g. after OAuth callback)
useEffect(() => {
const serverUrl = params.get("serverUrl");
if (serverUrl) {
setSseUrl(serverUrl);
setTransportType("sse");
// Remove serverUrl from URL without reloading the page
const newUrl = new URL(window.location.href);
newUrl.searchParams.delete("serverUrl");
window.history.replaceState({}, "", newUrl.toString());
// Show success toast for OAuth
toast.success("Successfully authenticated with OAuth");
// Connect to the server
connectMcpServer();
}
}, []);
useEffect(() => { useEffect(() => {
fetch(`${PROXY_SERVER_URL}/config`) fetch(`${PROXY_SERVER_URL}/config`)
.then((response) => response.json()) .then((response) => response.json())
@@ -257,29 +221,6 @@ const App = () => {
setErrors((prev) => ({ ...prev, [tabKey]: null })); setErrors((prev) => ({ ...prev, [tabKey]: null }));
}; };
const makeRequest = async <T extends z.ZodType>(
request: ClientRequest,
schema: T,
tabKey?: keyof typeof errors,
) => {
try {
const response = await makeConnectionRequest(request, schema);
if (tabKey !== undefined) {
clearError(tabKey);
}
return response;
} catch (e) {
const errorString = (e as Error).message ?? String(e);
if (tabKey !== undefined) {
setErrors((prev) => ({
...prev,
[tabKey]: errorString,
}));
}
throw e;
}
};
const listResources = async () => { const listResources = async () => {
const response = await makeRequest( const response = await makeRequest(
{ {
@@ -322,38 +263,6 @@ const App = () => {
setResourceContent(JSON.stringify(response, null, 2)); setResourceContent(JSON.stringify(response, null, 2));
}; };
const subscribeToResource = async (uri: string) => {
if (!resourceSubscriptions.has(uri)) {
await makeRequest(
{
method: "resources/subscribe" as const,
params: { uri },
},
z.object({}),
"resources",
);
const clone = new Set(resourceSubscriptions);
clone.add(uri);
setResourceSubscriptions(clone);
}
};
const unsubscribeFromResource = async (uri: string) => {
if (resourceSubscriptions.has(uri)) {
await makeRequest(
{
method: "resources/unsubscribe" as const,
params: { uri },
},
z.object({}),
"resources",
);
const clone = new Set(resourceSubscriptions);
clone.delete(uri);
setResourceSubscriptions(clone);
}
};
const listPrompts = async () => { const listPrompts = async () => {
const response = await makeRequest( const response = await makeRequest(
{ {
@@ -414,17 +323,6 @@ const App = () => {
await sendNotification({ method: "notifications/roots/list_changed" }); await sendNotification({ method: "notifications/roots/list_changed" });
}; };
const sendLogLevelRequest = async (level: LoggingLevel) => {
await makeRequest(
{
method: "logging/setLevel" as const,
params: { level },
},
z.object({}),
);
setLogLevel(level);
};
return ( return (
<div className="flex h-screen bg-background"> <div className="flex h-screen bg-background">
<Sidebar <Sidebar
@@ -439,13 +337,8 @@ const App = () => {
setSseUrl={setSseUrl} setSseUrl={setSseUrl}
env={env} env={env}
setEnv={setEnv} setEnv={setEnv}
bearerToken={bearerToken}
setBearerToken={setBearerToken}
onConnect={connectMcpServer} onConnect={connectMcpServer}
stdErrNotifications={stdErrNotifications} stdErrNotifications={stdErrNotifications}
logLevel={logLevel}
sendLogLevelRequest={sendLogLevelRequest}
loggingSupported={!!serverCapabilities?.logging || false}
/> />
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
@@ -547,20 +440,6 @@ const App = () => {
clearError("resources"); clearError("resources");
setSelectedResource(resource); setSelectedResource(resource);
}} }}
resourceSubscriptionsSupported={
serverCapabilities?.resources?.subscribe || false
}
resourceSubscriptions={resourceSubscriptions}
subscribeToResource={(uri) => {
clearError("resources");
subscribeToResource(uri);
}}
unsubscribeFromResource={(uri) => {
clearError("resources");
unsubscribeFromResource(uri);
}}
handleCompletion={handleCompletion}
completionsSupported={completionsSupported}
resourceContent={resourceContent} resourceContent={resourceContent}
nextCursor={nextResourceCursor} nextCursor={nextResourceCursor}
nextTemplateCursor={nextResourceTemplateCursor} nextTemplateCursor={nextResourceTemplateCursor}
@@ -585,8 +464,6 @@ const App = () => {
clearError("prompts"); clearError("prompts");
setSelectedPrompt(prompt); setSelectedPrompt(prompt);
}} }}
handleCompletion={handleCompletion}
completionsSupported={completionsSupported}
promptContent={promptContent} promptContent={promptContent}
nextCursor={nextPromptCursor} nextCursor={nextPromptCursor}
error={errors.prompts} error={errors.prompts}

View File

@@ -1,401 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import JsonEditor from "./JsonEditor";
import { updateValueAtPath, JsonObject } from "@/utils/jsonPathUtils";
import { generateDefaultValue, formatFieldLabel } from "@/utils/schemaUtils";
export type JsonValue =
| string
| number
| boolean
| null
| undefined
| JsonValue[]
| { [key: string]: JsonValue };
export type JsonSchemaType = {
type:
| "string"
| "number"
| "integer"
| "boolean"
| "array"
| "object"
| "null";
description?: string;
required?: boolean;
default?: JsonValue;
properties?: Record<string, JsonSchemaType>;
items?: JsonSchemaType;
};
interface DynamicJsonFormProps {
schema: JsonSchemaType;
value: JsonValue;
onChange: (value: JsonValue) => void;
maxDepth?: number;
}
const DynamicJsonForm = ({
schema,
value,
onChange,
maxDepth = 3,
}: DynamicJsonFormProps) => {
const [isJsonMode, setIsJsonMode] = useState(false);
const [jsonError, setJsonError] = useState<string>();
// Store the raw JSON string to allow immediate feedback during typing
// while deferring parsing until the user stops typing
const [rawJsonValue, setRawJsonValue] = useState<string>(
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
);
// Use a ref to manage debouncing timeouts to avoid parsing JSON
// on every keystroke which would be inefficient and error-prone
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
// Debounce JSON parsing and parent updates to handle typing gracefully
const debouncedUpdateParent = useCallback(
(jsonString: string) => {
// Clear any existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set a new timeout
timeoutRef.current = setTimeout(() => {
try {
const parsed = JSON.parse(jsonString);
onChange(parsed);
setJsonError(undefined);
} catch {
// Don't set error during normal typing
}
}, 300);
},
[onChange, setJsonError],
);
// Update rawJsonValue when value prop changes
useEffect(() => {
if (!isJsonMode) {
setRawJsonValue(
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
);
}
}, [value, schema, isJsonMode]);
const handleSwitchToFormMode = () => {
if (isJsonMode) {
// When switching to Form mode, ensure we have valid JSON
try {
const parsed = JSON.parse(rawJsonValue);
// Update the parent component's state with the parsed value
onChange(parsed);
// Switch to form mode
setIsJsonMode(false);
} catch (err) {
setJsonError(err instanceof Error ? err.message : "Invalid JSON");
}
} else {
// Update raw JSON value when switching to JSON mode
setRawJsonValue(
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
);
setIsJsonMode(true);
}
};
const renderFormFields = (
propSchema: JsonSchemaType,
currentValue: JsonValue,
path: string[] = [],
depth: number = 0,
) => {
if (
depth >= maxDepth &&
(propSchema.type === "object" || propSchema.type === "array")
) {
// Render as JSON editor when max depth is reached
return (
<JsonEditor
value={JSON.stringify(
currentValue ?? generateDefaultValue(propSchema),
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}
/>
);
}
switch (propSchema.type) {
case "string":
return (
<Input
type="text"
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);
}
}}
placeholder={propSchema.description}
required={propSchema.required}
/>
);
case "number":
return (
<Input
type="number"
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) {
handleFieldChange(path, undefined);
} else {
const num = Number(val);
if (!isNaN(num)) {
handleFieldChange(path, num);
}
}
}}
placeholder={propSchema.description}
required={propSchema.required}
/>
);
case "integer":
return (
<Input
type="number"
step="1"
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) {
handleFieldChange(path, undefined);
} else {
const num = Number(val);
// Only update if it's a valid integer
if (!isNaN(num) && Number.isInteger(num)) {
handleFieldChange(path, num);
}
}
}}
placeholder={propSchema.description}
required={propSchema.required}
/>
);
case "boolean":
return (
<Input
type="checkbox"
checked={(currentValue as boolean) ?? false}
onChange={(e) => handleFieldChange(path, e.target.checked)}
className="w-4 h-4"
required={propSchema.required}
/>
);
case "object": {
// Handle case where we have a value but no schema properties
const objectValue = (currentValue as JsonObject) || {};
// If we have schema properties, use them to render fields
if (propSchema.properties) {
return (
<div className="space-y-4 border rounded-md p-4">
{Object.entries(propSchema.properties).map(([key, prop]) => (
<div key={key} className="space-y-2">
<Label>{formatFieldLabel(key)}</Label>
{renderFormFields(
prop,
objectValue[key],
[...path, key],
depth + 1,
)}
</div>
))}
</div>
);
}
// If we have a value but no schema properties, render fields based on the value
else if (Object.keys(objectValue).length > 0) {
return (
<div className="space-y-4 border rounded-md p-4">
{Object.entries(objectValue).map(([key, value]) => (
<div key={key} className="space-y-2">
<Label>{formatFieldLabel(key)}</Label>
<Input
type="text"
value={String(value)}
onChange={(e) =>
handleFieldChange([...path, key], e.target.value)
}
/>
</div>
))}
</div>
);
}
// If we have neither schema properties nor value, return null
return null;
}
case "array": {
const arrayValue = Array.isArray(currentValue) ? currentValue : [];
if (!propSchema.items) return null;
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 = generateDefaultValue(
propSchema.items as JsonSchemaType,
);
handleFieldChange(path, [
...arrayValue,
defaultValue ?? null,
]);
}}
title={
propSchema.items?.description
? `Add new ${propSchema.items.description}`
: "Add new item"
}
>
Add Item
</Button>
</div>
</div>
);
}
default:
return null;
}
};
const handleFieldChange = (path: string[], fieldValue: JsonValue) => {
if (path.length === 0) {
onChange(fieldValue);
return;
}
try {
const newValue = updateValueAtPath(value, path, fieldValue);
onChange(newValue);
} catch (error) {
console.error("Failed to update form value:", error);
onChange(value);
}
};
const shouldUseJsonMode =
schema.type === "object" &&
(!schema.properties || Object.keys(schema.properties).length === 0);
useEffect(() => {
if (shouldUseJsonMode && !isJsonMode) {
setIsJsonMode(true);
}
}, [shouldUseJsonMode, isJsonMode]);
return (
<div className="space-y-4">
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
</Button>
</div>
{isJsonMode ? (
<JsonEditor
value={rawJsonValue}
onChange={(newValue) => {
// Always update local state
setRawJsonValue(newValue);
// Use the debounced function to attempt parsing and updating parent
debouncedUpdateParent(newValue);
}}
error={jsonError}
/>
) : // If schema type is object but value is not an object or is empty, and we have actual JSON data,
// render a simple representation of the JSON data
schema.type === "object" &&
(typeof value !== "object" ||
value === null ||
Object.keys(value).length === 0) &&
rawJsonValue &&
rawJsonValue !== "{}" ? (
<div className="space-y-4 border rounded-md p-4">
<p className="text-sm text-gray-500">
Form view not available for this JSON structure. Using simplified
view:
</p>
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto">
{rawJsonValue}
</pre>
<p className="text-sm text-gray-500">
Use JSON mode for full editing capabilities.
</p>
</div>
) : (
renderFormFields(schema, value)
)}
</div>
);
};
export default DynamicJsonForm;

View File

@@ -1,92 +0,0 @@
import { useState, useEffect } from "react";
import Editor from "react-simple-code-editor";
import Prism from "prismjs";
import "prismjs/components/prism-json";
import "prismjs/themes/prism.css";
import { Button } from "@/components/ui/button";
interface JsonEditorProps {
value: string;
onChange: (value: string) => void;
error?: string;
}
const JsonEditor = ({
value,
onChange,
error: externalError,
}: JsonEditorProps) => {
const [editorContent, setEditorContent] = useState(value);
const [internalError, setInternalError] = useState<string | undefined>(
undefined,
);
useEffect(() => {
setEditorContent(value);
}, [value]);
const formatJson = (json: string): string => {
try {
return JSON.stringify(JSON.parse(json), null, 2);
} catch {
return json;
}
};
const handleEditorChange = (newContent: string) => {
setEditorContent(newContent);
setInternalError(undefined);
onChange(newContent);
};
const handleFormatJson = () => {
try {
const formatted = formatJson(editorContent);
setEditorContent(formatted);
onChange(formatted);
setInternalError(undefined);
} catch (err) {
setInternalError(err instanceof Error ? err.message : "Invalid JSON");
}
};
const displayError = internalError || externalError;
return (
<div className="relative space-y-2">
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={handleFormatJson}>
Format JSON
</Button>
</div>
<div
className={`border rounded-md ${
displayError
? "border-red-500"
: "border-gray-200 dark:border-gray-800"
}`}
>
<Editor
value={editorContent}
onValueChange={handleEditorChange}
highlight={(code) =>
Prism.highlight(code, Prism.languages.json, "json")
}
padding={10}
style={{
fontFamily: '"Fira code", "Fira Mono", monospace',
fontSize: 14,
backgroundColor: "transparent",
minHeight: "100px",
}}
className="w-full"
/>
</div>
{displayError && (
<p className="text-sm text-red-500 mt-1">{displayError}</p>
)}
</div>
);
};
export default JsonEditor;

View File

@@ -0,0 +1,59 @@
import { render, screen, fireEvent } from "@testing-library/react";
import ListPane from "./ListPane";
import { describe, it, expect, vi } from "vitest";
describe("ListPane", () => {
const defaultProps = {
items: [
{ id: 1, name: "Item 1" },
{ id: 2, name: "Item 2" },
],
listItems: vi.fn(),
clearItems: vi.fn(),
setSelectedItem: vi.fn(),
renderItem: (item: { name: string }) => <span>{item.name}</span>,
title: "Test List",
buttonText: "List Items",
};
it("renders title correctly", () => {
render(<ListPane {...defaultProps} />);
expect(screen.getByText("Test List")).toBeInTheDocument();
});
it("renders list items using renderItem prop", () => {
render(<ListPane {...defaultProps} />);
expect(screen.getByText("Item 1")).toBeInTheDocument();
expect(screen.getByText("Item 2")).toBeInTheDocument();
});
it("calls listItems when List Items button is clicked", () => {
render(<ListPane {...defaultProps} />);
fireEvent.click(screen.getByText("List Items"));
expect(defaultProps.listItems).toHaveBeenCalledTimes(1);
});
it("calls clearItems when Clear button is clicked", () => {
render(<ListPane {...defaultProps} />);
fireEvent.click(screen.getByText("Clear"));
expect(defaultProps.clearItems).toHaveBeenCalledTimes(1);
});
it("calls setSelectedItem when an item is clicked", () => {
render(<ListPane {...defaultProps} />);
fireEvent.click(screen.getByText("Item 1"));
expect(defaultProps.setSelectedItem).toHaveBeenCalledWith(
defaultProps.items[0],
);
});
it("disables Clear button when items array is empty", () => {
render(<ListPane {...defaultProps} items={[]} />);
expect(screen.getByText("Clear")).toBeDisabled();
});
it("respects isButtonDisabled prop for List Items button", () => {
render(<ListPane {...defaultProps} isButtonDisabled={true} />);
expect(screen.getByText("List Items")).toBeDisabled();
});
});

View File

@@ -1,56 +0,0 @@
import { useEffect, useRef } from "react";
import { authProvider } from "../lib/auth";
import { SESSION_KEYS } from "../lib/constants";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
const OAuthCallback = () => {
const hasProcessedRef = useRef(false);
useEffect(() => {
const handleCallback = async () => {
// Skip if we've already processed this callback
if (hasProcessedRef.current) {
return;
}
hasProcessedRef.current = true;
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL);
if (!code || !serverUrl) {
console.error("Missing code or server URL");
window.location.href = "/";
return;
}
try {
const result = await auth(authProvider, {
serverUrl,
authorizationCode: code,
});
if (result !== "AUTHORIZED") {
throw new Error(
`Expected to be authorized after providing auth code, got: ${result}`,
);
}
// Redirect back to the main app with server URL to trigger auto-connect
window.location.href = `/?serverUrl=${encodeURIComponent(serverUrl)}`;
} catch (error) {
console.error("OAuth callback error:", error);
window.location.href = "/";
}
};
void handleCallback();
}, []);
return (
<div className="flex items-center justify-center h-screen">
<p className="text-lg text-gray-500">Processing OAuth callback...</p>
</div>
);
};
export default OAuthCallback;

View File

@@ -7,9 +7,11 @@ const PingTab = ({ onPingClick }: { onPingClick: () => void }) => {
<div className="col-span-2 flex justify-center items-center"> <div className="col-span-2 flex justify-center items-center">
<Button <Button
onClick={onPingClick} onClick={onPingClick}
className="font-bold py-6 px-12 rounded-full" className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white font-bold py-6 px-12 rounded-full shadow-lg transform transition duration-300 hover:scale-110 focus:outline-none focus:ring-4 focus:ring-purple-300 animate-pulse"
> >
Ping Server <span className="text-3xl mr-2">🚀</span>
MEGA PING
<span className="text-3xl ml-2">💥</span>
</Button> </Button>
</div> </div>
</TabsContent> </TabsContent>

View File

@@ -1,18 +1,13 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { TabsContent } from "@/components/ui/tabs"; import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import { ListPromptsResult } from "@modelcontextprotocol/sdk/types.js";
ListPromptsResult,
PromptReference,
ResourceReference,
} from "@modelcontextprotocol/sdk/types.js";
import { AlertCircle } from "lucide-react"; import { AlertCircle } from "lucide-react";
import { useEffect, useState } from "react"; import { useState } from "react";
import ListPane from "./ListPane"; import ListPane from "./ListPane";
import { useCompletionState } from "@/lib/hooks/useCompletionState";
export type Prompt = { export type Prompt = {
name: string; name: string;
@@ -31,8 +26,6 @@ const PromptsTab = ({
getPrompt, getPrompt,
selectedPrompt, selectedPrompt,
setSelectedPrompt, setSelectedPrompt,
handleCompletion,
completionsSupported,
promptContent, promptContent,
nextCursor, nextCursor,
error, error,
@@ -43,37 +36,14 @@ const PromptsTab = ({
getPrompt: (name: string, args: Record<string, string>) => void; getPrompt: (name: string, args: Record<string, string>) => void;
selectedPrompt: Prompt | null; selectedPrompt: Prompt | null;
setSelectedPrompt: (prompt: Prompt) => void; setSelectedPrompt: (prompt: Prompt) => void;
handleCompletion: (
ref: PromptReference | ResourceReference,
argName: string,
value: string,
) => Promise<string[]>;
completionsSupported: boolean;
promptContent: string; promptContent: string;
nextCursor: ListPromptsResult["nextCursor"]; nextCursor: ListPromptsResult["nextCursor"];
error: string | null; error: string | null;
}) => { }) => {
const [promptArgs, setPromptArgs] = useState<Record<string, string>>({}); const [promptArgs, setPromptArgs] = useState<Record<string, string>>({});
const { completions, clearCompletions, requestCompletions } =
useCompletionState(handleCompletion, completionsSupported);
useEffect(() => { const handleInputChange = (argName: string, value: string) => {
clearCompletions();
}, [clearCompletions, selectedPrompt]);
const handleInputChange = async (argName: string, value: string) => {
setPromptArgs((prev) => ({ ...prev, [argName]: value })); setPromptArgs((prev) => ({ ...prev, [argName]: value }));
if (selectedPrompt) {
requestCompletions(
{
type: "ref/prompt",
name: selectedPrompt.name,
},
argName,
value,
);
}
}; };
const handleGetPrompt = () => { const handleGetPrompt = () => {
@@ -126,17 +96,14 @@ const PromptsTab = ({
{selectedPrompt.arguments?.map((arg) => ( {selectedPrompt.arguments?.map((arg) => (
<div key={arg.name}> <div key={arg.name}>
<Label htmlFor={arg.name}>{arg.name}</Label> <Label htmlFor={arg.name}>{arg.name}</Label>
<Combobox <Input
id={arg.name} id={arg.name}
placeholder={`Enter ${arg.name}`} placeholder={`Enter ${arg.name}`}
value={promptArgs[arg.name] || ""} value={promptArgs[arg.name] || ""}
onChange={(value) => handleInputChange(arg.name, value)} onChange={(e) =>
onInputChange={(value) => handleInputChange(arg.name, e.target.value)
handleInputChange(arg.name, value)
} }
options={completions[arg.name] || []}
/> />
{arg.description && ( {arg.description && (
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1">
{arg.description} {arg.description}

View File

@@ -1,20 +1,16 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input";
import { Combobox } from "@/components/ui/combobox";
import { TabsContent } from "@/components/ui/tabs"; import { TabsContent } from "@/components/ui/tabs";
import { import {
ListResourcesResult, ListResourcesResult,
Resource, Resource,
ResourceTemplate, ResourceTemplate,
ListResourceTemplatesResult, ListResourceTemplatesResult,
ResourceReference,
PromptReference,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import { AlertCircle, ChevronRight, FileText, RefreshCw } from "lucide-react"; import { AlertCircle, ChevronRight, FileText, RefreshCw } from "lucide-react";
import ListPane from "./ListPane"; import ListPane from "./ListPane";
import { useEffect, useState } from "react"; import { useState } from "react";
import { useCompletionState } from "@/lib/hooks/useCompletionState";
const ResourcesTab = ({ const ResourcesTab = ({
resources, resources,
@@ -26,12 +22,6 @@ const ResourcesTab = ({
readResource, readResource,
selectedResource, selectedResource,
setSelectedResource, setSelectedResource,
resourceSubscriptionsSupported,
resourceSubscriptions,
subscribeToResource,
unsubscribeFromResource,
handleCompletion,
completionsSupported,
resourceContent, resourceContent,
nextCursor, nextCursor,
nextTemplateCursor, nextTemplateCursor,
@@ -46,20 +36,10 @@ const ResourcesTab = ({
readResource: (uri: string) => void; readResource: (uri: string) => void;
selectedResource: Resource | null; selectedResource: Resource | null;
setSelectedResource: (resource: Resource | null) => void; setSelectedResource: (resource: Resource | null) => void;
handleCompletion: (
ref: ResourceReference | PromptReference,
argName: string,
value: string,
) => Promise<string[]>;
completionsSupported: boolean;
resourceContent: string; resourceContent: string;
nextCursor: ListResourcesResult["nextCursor"]; nextCursor: ListResourcesResult["nextCursor"];
nextTemplateCursor: ListResourceTemplatesResult["nextCursor"]; nextTemplateCursor: ListResourceTemplatesResult["nextCursor"];
error: string | null; error: string | null;
resourceSubscriptionsSupported: boolean;
resourceSubscriptions: Set<string>;
subscribeToResource: (uri: string) => void;
unsubscribeFromResource: (uri: string) => void;
}) => { }) => {
const [selectedTemplate, setSelectedTemplate] = const [selectedTemplate, setSelectedTemplate] =
useState<ResourceTemplate | null>(null); useState<ResourceTemplate | null>(null);
@@ -67,13 +47,6 @@ const ResourcesTab = ({
{}, {},
); );
const { completions, clearCompletions, requestCompletions } =
useCompletionState(handleCompletion, completionsSupported);
useEffect(() => {
clearCompletions();
}, [clearCompletions]);
const fillTemplate = ( const fillTemplate = (
template: string, template: string,
values: Record<string, string>, values: Record<string, string>,
@@ -84,21 +57,6 @@ const ResourcesTab = ({
); );
}; };
const handleTemplateValueChange = async (key: string, value: string) => {
setTemplateValues((prev) => ({ ...prev, [key]: value }));
if (selectedTemplate?.uriTemplate) {
requestCompletions(
{
type: "ref/resource",
uri: selectedTemplate.uriTemplate,
},
key,
value,
);
}
};
const handleReadTemplateResource = () => { const handleReadTemplateResource = () => {
if (selectedTemplate) { if (selectedTemplate) {
const uri = fillTemplate(selectedTemplate.uriTemplate, templateValues); const uri = fillTemplate(selectedTemplate.uriTemplate, templateValues);
@@ -172,38 +130,14 @@ const ResourcesTab = ({
: "Select a resource or template"} : "Select a resource or template"}
</h3> </h3>
{selectedResource && ( {selectedResource && (
<div className="flex row-auto gap-1 justify-end w-2/5"> <Button
{resourceSubscriptionsSupported && variant="outline"
!resourceSubscriptions.has(selectedResource.uri) && ( size="sm"
<Button onClick={() => readResource(selectedResource.uri)}
variant="outline" >
size="sm" <RefreshCw className="w-4 h-4 mr-2" />
onClick={() => subscribeToResource(selectedResource.uri)} Refresh
> </Button>
Subscribe
</Button>
)}
{resourceSubscriptionsSupported &&
resourceSubscriptions.has(selectedResource.uri) && (
<Button
variant="outline"
size="sm"
onClick={() =>
unsubscribeFromResource(selectedResource.uri)
}
>
Unsubscribe
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => readResource(selectedResource.uri)}
>
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
</div>
)} )}
</div> </div>
<div className="p-4"> <div className="p-4">
@@ -228,18 +162,22 @@ const ResourcesTab = ({
const key = param.slice(1, -1); const key = param.slice(1, -1);
return ( return (
<div key={key}> <div key={key}>
<Label htmlFor={key}>{key}</Label> <label
<Combobox htmlFor={key}
className="block text-sm font-medium text-gray-700"
>
{key}
</label>
<Input
id={key} id={key}
placeholder={`Enter ${key}`}
value={templateValues[key] || ""} value={templateValues[key] || ""}
onChange={(value) => onChange={(e) =>
handleTemplateValueChange(key, value) setTemplateValues({
...templateValues,
[key]: e.target.value,
})
} }
onInputChange={(value) => className="mt-1"
handleTemplateValueChange(key, value)
}
options={completions[key] || []}
/> />
</div> </div>
); );

View File

@@ -43,7 +43,7 @@ const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
<h3 className="text-lg font-semibold">Recent Requests</h3> <h3 className="text-lg font-semibold">Recent Requests</h3>
{pendingRequests.map((request) => ( {pendingRequests.map((request) => (
<div key={request.id} className="p-4 border rounded-lg space-y-4"> <div key={request.id} className="p-4 border rounded-lg space-y-4">
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded"> <pre className="bg-gray-50 p-2 rounded">
{JSON.stringify(request.request, null, 2)} {JSON.stringify(request.request, null, 2)}
</pre> </pre>
<div className="flex space-x-2"> <div className="flex space-x-2">

View File

@@ -19,10 +19,6 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { StdErrNotification } from "@/lib/notificationTypes"; import { StdErrNotification } from "@/lib/notificationTypes";
import {
LoggingLevel,
LoggingLevelSchema,
} from "@modelcontextprotocol/sdk/types.js";
import useTheme from "../lib/useTheme"; import useTheme from "../lib/useTheme";
import { version } from "../../../package.json"; import { version } from "../../../package.json";
@@ -39,13 +35,8 @@ interface SidebarProps {
setSseUrl: (url: string) => void; setSseUrl: (url: string) => void;
env: Record<string, string>; env: Record<string, string>;
setEnv: (env: Record<string, string>) => void; setEnv: (env: Record<string, string>) => void;
bearerToken: string;
setBearerToken: (token: string) => void;
onConnect: () => void; onConnect: () => void;
stdErrNotifications: StdErrNotification[]; stdErrNotifications: StdErrNotification[];
logLevel: LoggingLevel;
sendLogLevelRequest: (level: LoggingLevel) => void;
loggingSupported: boolean;
} }
const Sidebar = ({ const Sidebar = ({
@@ -60,17 +51,11 @@ const Sidebar = ({
setSseUrl, setSseUrl,
env, env,
setEnv, setEnv,
bearerToken,
setBearerToken,
onConnect, onConnect,
stdErrNotifications, stdErrNotifications,
logLevel,
sendLogLevelRequest,
loggingSupported,
}: SidebarProps) => { }: SidebarProps) => {
const [theme, setTheme] = useTheme(); const [theme, setTheme] = useTheme();
const [showEnvVars, setShowEnvVars] = useState(false); const [showEnvVars, setShowEnvVars] = useState(false);
const [showBearerToken, setShowBearerToken] = useState(false);
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set()); const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
return ( return (
@@ -125,43 +110,15 @@ const Sidebar = ({
</div> </div>
</> </>
) : ( ) : (
<> <div className="space-y-2">
<div className="space-y-2"> <label className="text-sm font-medium">URL</label>
<label className="text-sm font-medium">URL</label> <Input
<Input placeholder="URL"
placeholder="URL" value={sseUrl}
value={sseUrl} onChange={(e) => setSseUrl(e.target.value)}
onChange={(e) => setSseUrl(e.target.value)} className="font-mono"
className="font-mono" />
/> </div>
</div>
<div className="space-y-2">
<Button
variant="outline"
onClick={() => setShowBearerToken(!showBearerToken)}
className="flex items-center w-full"
>
{showBearerToken ? (
<ChevronDown className="w-4 h-4 mr-2" />
) : (
<ChevronRight className="w-4 h-4 mr-2" />
)}
Authentication
</Button>
{showBearerToken && (
<div className="space-y-2">
<label className="text-sm font-medium">Bearer Token</label>
<Input
placeholder="Bearer Token"
value={bearerToken}
onChange={(e) => setBearerToken(e.target.value)}
className="font-mono"
type="password"
/>
</div>
)}
</div>
</>
)} )}
{transportType === "stdio" && ( {transportType === "stdio" && (
<div className="space-y-2"> <div className="space-y-2">
@@ -187,17 +144,9 @@ const Sidebar = ({
value={key} value={key}
onChange={(e) => { onChange={(e) => {
const newKey = e.target.value; const newKey = e.target.value;
const newEnv = Object.entries(env).reduce( const newEnv = { ...env };
(acc, [k, v]) => { delete newEnv[key];
if (k === key) { newEnv[newKey] = value;
acc[newKey] = value;
} else {
acc[k] = v;
}
return acc;
},
{} as Record<string, string>,
);
setEnv(newEnv); setEnv(newEnv);
setShownEnvVars((prev) => { setShownEnvVars((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -308,28 +257,6 @@ const Sidebar = ({
: "Disconnected"} : "Disconnected"}
</span> </span>
</div> </div>
{loggingSupported && connectionStatus === "connected" && (
<div className="space-y-2">
<label className="text-sm font-medium">Logging Level</label>
<Select
value={logLevel}
onValueChange={(value: LoggingLevel) =>
sendLogLevelRequest(value)
}
>
<SelectTrigger>
<SelectValue placeholder="Select logging level" />
</SelectTrigger>
<SelectContent>
{Object.values(LoggingLevelSchema.enum).map((level) => (
<SelectItem value={level}>{level}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{stdErrNotifications.length > 0 && ( {stdErrNotifications.length > 0 && (
<> <>
<div className="mt-4 border-t border-gray-200 pt-4"> <div className="mt-4 border-t border-gray-200 pt-4">

View File

@@ -1,22 +1,19 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { TabsContent } from "@/components/ui/tabs"; import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import DynamicJsonForm, { JsonSchemaType, JsonValue } from "./DynamicJsonForm";
import { generateDefaultValue } from "@/utils/schemaUtils";
import { import {
CallToolResultSchema,
CompatibilityCallToolResult,
ListToolsResult, ListToolsResult,
Tool, Tool,
CallToolResultSchema,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import { AlertCircle, Send } from "lucide-react"; import { AlertCircle, Send } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import ListPane from "./ListPane"; import ListPane from "./ListPane";
import { escapeUnicode } from "@/utils/escapeUnicode";
import { CompatibilityCallToolResult } from "@modelcontextprotocol/sdk/types.js";
const ToolsTab = ({ const ToolsTab = ({
tools, tools,
@@ -54,7 +51,7 @@ const ToolsTab = ({
<> <>
<h4 className="font-semibold mb-2">Invalid Tool Result:</h4> <h4 className="font-semibold mb-2">Invalid Tool Result:</h4>
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64"> <pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64">
{escapeUnicode(toolResult)} {JSON.stringify(toolResult, null, 2)}
</pre> </pre>
<h4 className="font-semibold mb-2">Errors:</h4> <h4 className="font-semibold mb-2">Errors:</h4>
{parsedResult.error.errors.map((error, idx) => ( {parsedResult.error.errors.map((error, idx) => (
@@ -62,7 +59,7 @@ const ToolsTab = ({
key={idx} key={idx}
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64" className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64"
> >
{escapeUnicode(error)} {JSON.stringify(error, null, 2)}
</pre> </pre>
))} ))}
</> </>
@@ -90,20 +87,11 @@ const ToolsTab = ({
className="max-w-full h-auto" className="max-w-full h-auto"
/> />
)} )}
{item.type === "resource" && {item.type === "resource" && (
(item.resource?.mimeType?.startsWith("audio/") ? ( <pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 whitespace-pre-wrap break-words p-4 rounded text-sm overflow-auto max-h-64">
<audio {JSON.stringify(item.resource, null, 2)}
controls </pre>
src={`data:${item.resource.mimeType};base64,${item.resource.blob}`} )}
className="w-full"
>
<p>Your browser does not support audio playback</p>
</audio>
) : (
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 whitespace-pre-wrap break-words p-4 rounded text-sm overflow-auto max-h-64">
{escapeUnicode(item.resource)}
</pre>
))}
</div> </div>
))} ))}
</> </>
@@ -113,7 +101,7 @@ const ToolsTab = ({
<> <>
<h4 className="font-semibold mb-2">Tool Result (Legacy):</h4> <h4 className="font-semibold mb-2">Tool Result (Legacy):</h4>
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64"> <pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64">
{escapeUnicode(toolResult.toolResult)} {JSON.stringify(toolResult.toolResult, null, 2)}
</pre> </pre>
</> </>
); );
@@ -162,42 +150,22 @@ const ToolsTab = ({
{selectedTool.description} {selectedTool.description}
</p> </p>
{Object.entries(selectedTool.inputSchema.properties ?? []).map( {Object.entries(selectedTool.inputSchema.properties ?? []).map(
([key, value]) => { ([key, value]) => (
const prop = value as JsonSchemaType; <div key={key}>
return ( <Label
<div key={key}> htmlFor={key}
<Label className="block text-sm font-medium text-gray-700"
htmlFor={key} >
className="block text-sm font-medium text-gray-700" {key}
> </Label>
{key} {
</Label> /* @ts-expect-error value type is currently unknown */
{prop.type === "boolean" ? ( value.type === "string" ? (
<div className="flex items-center space-x-2 mt-2">
<Checkbox
id={key}
name={key}
checked={!!params[key]}
onCheckedChange={(checked: boolean) =>
setParams({
...params,
[key]: checked,
})
}
/>
<label
htmlFor={key}
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{prop.description || "Toggle this option"}
</label>
</div>
) : prop.type === "string" ? (
<Textarea <Textarea
id={key} id={key}
name={key} name={key}
placeholder={prop.description} // @ts-expect-error value type is currently unknown
value={(params[key] as string) ?? ""} placeholder={value.description}
onChange={(e) => onChange={(e) =>
setParams({ setParams({
...params, ...params,
@@ -206,49 +174,54 @@ const ToolsTab = ({
} }
className="mt-1" className="mt-1"
/> />
) : prop.type === "object" || prop.type === "array" ? ( ) : /* @ts-expect-error value type is currently unknown */
<div className="mt-1"> value.type === "object" ? (
<DynamicJsonForm <Textarea
schema={{
type: prop.type,
properties: prop.properties,
description: prop.description,
items: prop.items,
}}
value={
(params[key] as JsonValue) ??
generateDefaultValue(prop)
}
onChange={(newValue: JsonValue) => {
setParams({
...params,
[key]: newValue,
});
}}
/>
</div>
) : (
<Input
type={prop.type === "number" ? "number" : "text"}
id={key} id={key}
name={key} name={key}
placeholder={prop.description} // @ts-expect-error value type is currently unknown
value={(params[key] as string) ?? ""} placeholder={value.description}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
setParams({
...params,
[key]: parsed,
});
} catch (err) {
// If invalid JSON, store as string - will be validated on submit
setParams({
...params,
[key]: e.target.value,
});
}
}}
className="mt-1"
/>
) : (
<Input
// @ts-expect-error value type is currently unknown
type={value.type === "number" ? "number" : "text"}
id={key}
name={key}
// @ts-expect-error value type is currently unknown
placeholder={value.description}
onChange={(e) => onChange={(e) =>
setParams({ setParams({
...params, ...params,
[key]: [key]:
prop.type === "number" // @ts-expect-error value type is currently unknown
value.type === "number"
? Number(e.target.value) ? Number(e.target.value)
: e.target.value, : e.target.value,
}) })
} }
className="mt-1" className="mt-1"
/> />
)} )
</div> }
); </div>
}, ),
)} )}
<Button onClick={() => callTool(selectedTool.name, params)}> <Button onClick={() => callTool(selectedTool.name, params)}>
<Send className="w-4 h-4 mr-2" /> <Send className="w-4 h-4 mr-2" />

View File

@@ -0,0 +1,55 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { Button } from "./button";
import { describe, it, expect, vi } from "vitest";
import { createRef } from "react";
describe("Button", () => {
it("renders children correctly", () => {
render(<Button>Click me</Button>);
expect(screen.getByText("Click me")).toBeInTheDocument();
});
it("handles click events", () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByText("Click me"));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it("applies different variants correctly", () => {
const { rerender } = render(<Button variant="default">Default</Button>);
expect(screen.getByText("Default")).toHaveClass("bg-primary");
rerender(<Button variant="outline">Outline</Button>);
expect(screen.getByText("Outline")).toHaveClass("border-input");
rerender(<Button variant="secondary">Secondary</Button>);
expect(screen.getByText("Secondary")).toHaveClass("bg-secondary");
});
it("applies different sizes correctly", () => {
const { rerender } = render(<Button size="default">Default</Button>);
expect(screen.getByText("Default")).toHaveClass("h-9");
rerender(<Button size="sm">Small</Button>);
expect(screen.getByText("Small")).toHaveClass("h-8");
rerender(<Button size="lg">Large</Button>);
expect(screen.getByText("Large")).toHaveClass("h-10");
});
it("forwards ref correctly", () => {
const ref = createRef<HTMLButtonElement>();
render(<Button ref={ref}>Button with ref</Button>);
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
});
it("renders as a different element when asChild is true", () => {
render(
<Button asChild>
<a href="#">Link Button</a>
</Button>,
);
expect(screen.getByText("Link Button").tagName).toBe("A");
});
});

View File

@@ -1,30 +0,0 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@@ -1,97 +0,0 @@
import React from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
interface ComboboxProps {
value: string;
onChange: (value: string) => void;
onInputChange: (value: string) => void;
options: string[];
placeholder?: string;
emptyMessage?: string;
id?: string;
}
export function Combobox({
value,
onChange,
onInputChange,
options = [],
placeholder = "Select...",
emptyMessage = "No results found.",
id,
}: ComboboxProps) {
const [open, setOpen] = React.useState(false);
const handleSelect = React.useCallback(
(option: string) => {
onChange(option);
setOpen(false);
},
[onChange],
);
const handleInputChange = React.useCallback(
(value: string) => {
onInputChange(value);
},
[onInputChange],
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
aria-controls={id}
className="w-full justify-between"
>
{value || placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command shouldFilter={false} id={id}>
<CommandInput
placeholder={placeholder}
value={value}
onValueChange={handleInputChange}
/>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option}
value={option}
onSelect={() => handleSelect(option)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === option ? "opacity-100" : "opacity-0",
)}
/>
{option}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,150 +0,0 @@
import * as React from "react";
import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { cn } from "@/lib/utils";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className,
)}
{...props}
/>
);
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -1,121 +0,0 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cn } from "@/lib/utils";
import { Cross2Icon } from "@radix-ui/react-icons";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -1,31 +0,0 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -57,10 +57,6 @@ button:focus-visible {
outline: 4px auto -webkit-focus-ring-color; outline: 4px auto -webkit-focus-ring-color;
} }
button[role="checkbox"] {
padding: 0;
}
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
:root { :root {
color: #213547; color: #213547;

View File

@@ -1,73 +0,0 @@
import { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
import {
OAuthClientInformationSchema,
OAuthClientInformation,
OAuthTokens,
OAuthTokensSchema,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import { SESSION_KEYS } from "./constants";
class InspectorOAuthClientProvider implements OAuthClientProvider {
get redirectUrl() {
return window.location.origin + "/oauth/callback";
}
get clientMetadata() {
return {
redirect_uris: [this.redirectUrl],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
client_name: "MCP Inspector",
client_uri: "https://github.com/modelcontextprotocol/inspector",
};
}
async clientInformation() {
const value = sessionStorage.getItem(SESSION_KEYS.CLIENT_INFORMATION);
if (!value) {
return undefined;
}
return await OAuthClientInformationSchema.parseAsync(JSON.parse(value));
}
saveClientInformation(clientInformation: OAuthClientInformation) {
sessionStorage.setItem(
SESSION_KEYS.CLIENT_INFORMATION,
JSON.stringify(clientInformation),
);
}
async tokens() {
const tokens = sessionStorage.getItem(SESSION_KEYS.TOKENS);
if (!tokens) {
return undefined;
}
return await OAuthTokensSchema.parseAsync(JSON.parse(tokens));
}
saveTokens(tokens: OAuthTokens) {
sessionStorage.setItem(SESSION_KEYS.TOKENS, JSON.stringify(tokens));
}
redirectToAuthorization(authorizationUrl: URL) {
window.location.href = authorizationUrl.href;
}
saveCodeVerifier(codeVerifier: string) {
sessionStorage.setItem(SESSION_KEYS.CODE_VERIFIER, codeVerifier);
}
codeVerifier() {
const verifier = sessionStorage.getItem(SESSION_KEYS.CODE_VERIFIER);
if (!verifier) {
throw new Error("No code verifier saved for session");
}
return verifier;
}
}
export const authProvider = new InspectorOAuthClientProvider();

View File

@@ -1,7 +0,0 @@
// OAuth-related session storage keys
export const SESSION_KEYS = {
CODE_VERIFIER: "mcp_code_verifier",
SERVER_URL: "mcp_server_url",
TOKENS: "mcp_tokens",
CLIENT_INFORMATION: "mcp_client_information",
} as const;

View File

@@ -1,128 +0,0 @@
import { useState, useCallback, useEffect, useRef } from "react";
import {
ResourceReference,
PromptReference,
} from "@modelcontextprotocol/sdk/types.js";
interface CompletionState {
completions: Record<string, string[]>;
loading: Record<string, boolean>;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function debounce<T extends (...args: any[]) => PromiseLike<void>>(
func: T,
wait: number,
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout>;
return function (...args: Parameters<T>) {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
export function useCompletionState(
handleCompletion: (
ref: ResourceReference | PromptReference,
argName: string,
value: string,
signal?: AbortSignal,
) => Promise<string[]>,
completionsSupported: boolean = true,
debounceMs: number = 300,
) {
const [state, setState] = useState<CompletionState>({
completions: {},
loading: {},
});
const abortControllerRef = useRef<AbortController | null>(null);
const cleanup = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
}, []);
// Cleanup on unmount
useEffect(() => {
return cleanup;
}, [cleanup]);
const clearCompletions = useCallback(() => {
cleanup();
setState({
completions: {},
loading: {},
});
}, [cleanup]);
const requestCompletions = useCallback(
debounce(
async (
ref: ResourceReference | PromptReference,
argName: string,
value: string,
) => {
if (!completionsSupported) {
return;
}
cleanup();
const abortController = new AbortController();
abortControllerRef.current = abortController;
setState((prev) => ({
...prev,
loading: { ...prev.loading, [argName]: true },
}));
try {
const values = await handleCompletion(
ref,
argName,
value,
abortController.signal,
);
if (!abortController.signal.aborted) {
setState((prev) => ({
...prev,
completions: { ...prev.completions, [argName]: values },
loading: { ...prev.loading, [argName]: false },
}));
}
} catch (err) {
if (!abortController.signal.aborted) {
setState((prev) => ({
...prev,
loading: { ...prev.loading, [argName]: false },
}));
}
} finally {
if (abortControllerRef.current === abortController) {
abortControllerRef.current = null;
}
}
},
debounceMs,
),
[handleCompletion, completionsSupported, cleanup, debounceMs],
);
// Clear completions when support status changes
useEffect(() => {
if (!completionsSupported) {
clearCompletions();
}
}, [completionsSupported, clearCompletions]);
return {
...state,
clearCompletions,
requestCompletions,
completionsSupported,
};
}

View File

@@ -1,37 +1,21 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
SSEClientTransport,
SseError,
} from "@modelcontextprotocol/sdk/client/sse.js";
import { import {
ClientNotification, ClientNotification,
ClientRequest, ClientRequest,
CreateMessageRequestSchema, CreateMessageRequestSchema,
ListRootsRequestSchema, ListRootsRequestSchema,
ProgressNotificationSchema, ProgressNotificationSchema,
ResourceUpdatedNotificationSchema,
LoggingMessageNotificationSchema,
Request, Request,
Result, Result,
ServerCapabilities, ServerCapabilities,
PromptReference,
ResourceReference,
McpError,
CompleteResultSchema,
ErrorCode,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import { useState } from "react"; import { useState } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { z } from "zod";
import { SESSION_KEYS } from "../constants";
import { Notification, StdErrNotificationSchema } from "../notificationTypes"; import { Notification, StdErrNotificationSchema } from "../notificationTypes";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; import { z } from "zod";
import { authProvider } from "../auth";
import packageJson from "../../../package.json";
const params = new URLSearchParams(window.location.search); const DEFAULT_REQUEST_TIMEOUT_MSEC = 10000;
const DEFAULT_REQUEST_TIMEOUT_MSEC =
parseInt(params.get("timeout") ?? "") || 10000;
interface UseConnectionOptions { interface UseConnectionOptions {
transportType: "stdio" | "sse"; transportType: "stdio" | "sse";
@@ -40,7 +24,6 @@ interface UseConnectionOptions {
sseUrl: string; sseUrl: string;
env: Record<string, string>; env: Record<string, string>;
proxyServerUrl: string; proxyServerUrl: string;
bearerToken?: string;
requestTimeout?: number; requestTimeout?: number;
onNotification?: (notification: Notification) => void; onNotification?: (notification: Notification) => void;
onStdErrNotification?: (notification: Notification) => void; onStdErrNotification?: (notification: Notification) => void;
@@ -48,12 +31,6 @@ interface UseConnectionOptions {
getRoots?: () => any[]; getRoots?: () => any[];
} }
interface RequestOptions {
signal?: AbortSignal;
timeout?: number;
suppressToast?: boolean;
}
export function useConnection({ export function useConnection({
transportType, transportType,
command, command,
@@ -61,7 +38,6 @@ export function useConnection({
sseUrl, sseUrl,
env, env,
proxyServerUrl, proxyServerUrl,
bearerToken,
requestTimeout = DEFAULT_REQUEST_TIMEOUT_MSEC, requestTimeout = DEFAULT_REQUEST_TIMEOUT_MSEC,
onNotification, onNotification,
onStdErrNotification, onStdErrNotification,
@@ -77,7 +53,6 @@ export function useConnection({
const [requestHistory, setRequestHistory] = useState< const [requestHistory, setRequestHistory] = useState<
{ request: string; response?: string }[] { request: string; response?: string }[]
>([]); >([]);
const [completionsSupported, setCompletionsSupported] = useState(true);
const pushHistory = (request: object, response?: object) => { const pushHistory = (request: object, response?: object) => {
setRequestHistory((prev) => [ setRequestHistory((prev) => [
@@ -92,8 +67,7 @@ export function useConnection({
const makeRequest = async <T extends z.ZodType>( const makeRequest = async <T extends z.ZodType>(
request: ClientRequest, request: ClientRequest,
schema: T, schema: T,
options?: RequestOptions, ) => {
): Promise<z.output<T>> => {
if (!mcpClient) { if (!mcpClient) {
throw new Error("MCP client not connected"); throw new Error("MCP client not connected");
} }
@@ -102,12 +76,12 @@ export function useConnection({
const abortController = new AbortController(); const abortController = new AbortController();
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
abortController.abort("Request timed out"); abortController.abort("Request timed out");
}, options?.timeout ?? requestTimeout); }, requestTimeout);
let response; let response;
try { try {
response = await mcpClient.request(request, schema, { response = await mcpClient.request(request, schema, {
signal: options?.signal ?? abortController.signal, signal: abortController.signal,
}); });
pushHistory(request, response); pushHistory(request, response);
} catch (error) { } catch (error) {
@@ -121,93 +95,33 @@ export function useConnection({
return response; return response;
} catch (e: unknown) { } catch (e: unknown) {
if (!options?.suppressToast) { const errorString = (e as Error).message ?? String(e);
const errorString = (e as Error).message ?? String(e); toast.error(errorString);
toast.error(errorString);
}
throw e;
}
};
const handleCompletion = async (
ref: ResourceReference | PromptReference,
argName: string,
value: string,
signal?: AbortSignal,
): Promise<string[]> => {
if (!mcpClient || !completionsSupported) {
return [];
}
const request: ClientRequest = {
method: "completion/complete",
params: {
argument: {
name: argName,
value,
},
ref,
},
};
try {
const response = await makeRequest(request, CompleteResultSchema, {
signal,
suppressToast: true,
});
return response?.completion.values || [];
} catch (e: unknown) {
// Disable completions silently if the server doesn't support them.
// See https://github.com/modelcontextprotocol/specification/discussions/122
if (e instanceof McpError && e.code === ErrorCode.MethodNotFound) {
setCompletionsSupported(false);
return [];
}
// Unexpected errors - show toast and rethrow
toast.error(e instanceof Error ? e.message : String(e));
throw e; throw e;
} }
}; };
const sendNotification = async (notification: ClientNotification) => { const sendNotification = async (notification: ClientNotification) => {
if (!mcpClient) { if (!mcpClient) {
const error = new Error("MCP client not connected"); throw new Error("MCP client not connected");
toast.error(error.message);
throw error;
} }
try { try {
await mcpClient.notification(notification); await mcpClient.notification(notification);
// Log successful notifications
pushHistory(notification); pushHistory(notification);
} catch (e: unknown) { } catch (e: unknown) {
if (e instanceof McpError) { toast.error((e as Error).message ?? String(e));
// Log MCP protocol errors
pushHistory(notification, { error: e.message });
}
toast.error(e instanceof Error ? e.message : String(e));
throw e; throw e;
} }
}; };
const handleAuthError = async (error: unknown) => { const connect = async () => {
if (error instanceof SseError && error.code === 401) {
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, sseUrl);
const result = await auth(authProvider, { serverUrl: sseUrl });
return result === "AUTHORIZED";
}
return false;
};
const connect = async (_e?: unknown, retryCount: number = 0) => {
try { try {
const client = new Client<Request, Notification, Result>( const client = new Client<Request, Notification, Result>(
{ {
name: "mcp-inspector", name: "mcp-inspector",
version: packageJson.version, version: "0.0.1",
}, },
{ {
capabilities: { capabilities: {
@@ -230,40 +144,13 @@ export function useConnection({
backendUrl.searchParams.append("url", sseUrl); backendUrl.searchParams.append("url", sseUrl);
} }
// Inject auth manually instead of using SSEClientTransport, because we're const clientTransport = new SSEClientTransport(backendUrl);
// proxying through the inspector server first.
const headers: HeadersInit = {};
// Use manually provided bearer token if available, otherwise use OAuth tokens
const token = bearerToken || (await authProvider.tokens())?.access_token;
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const clientTransport = new SSEClientTransport(backendUrl, {
eventSourceInit: {
fetch: (url, init) => fetch(url, { ...init, headers }),
},
requestInit: {
headers,
},
});
if (onNotification) { if (onNotification) {
client.setNotificationHandler( client.setNotificationHandler(
ProgressNotificationSchema, ProgressNotificationSchema,
onNotification, onNotification,
); );
client.setNotificationHandler(
ResourceUpdatedNotificationSchema,
onNotification,
);
client.setNotificationHandler(
LoggingMessageNotificationSchema,
onNotification,
);
} }
if (onStdErrNotification) { if (onStdErrNotification) {
@@ -273,25 +160,10 @@ export function useConnection({
); );
} }
try { await client.connect(clientTransport);
await client.connect(clientTransport);
} catch (error) {
console.error("Failed to connect to MCP server:", error);
const shouldRetry = await handleAuthError(error);
if (shouldRetry) {
return connect(undefined, retryCount + 1);
}
if (error instanceof SseError && error.code === 401) {
// Don't set error state if we're about to redirect for auth
return;
}
throw error;
}
const capabilities = client.getServerCapabilities(); const capabilities = client.getServerCapabilities();
setServerCapabilities(capabilities ?? null); setServerCapabilities(capabilities ?? null);
setCompletionsSupported(true); // Reset completions support on new connection
if (onPendingRequest) { if (onPendingRequest) {
client.setRequestHandler(CreateMessageRequestSchema, (request) => { client.setRequestHandler(CreateMessageRequestSchema, (request) => {
@@ -322,8 +194,6 @@ export function useConnection({
requestHistory, requestHistory,
makeRequest, makeRequest,
sendNotification, sendNotification,
handleCompletion,
completionsSupported,
connect, connect,
}; };
} }

View File

@@ -1,7 +1,6 @@
import { import {
NotificationSchema as BaseNotificationSchema, NotificationSchema as BaseNotificationSchema,
ClientNotificationSchema, ClientNotificationSchema,
ServerNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod"; import { z } from "zod";
@@ -14,7 +13,7 @@ export const StdErrNotificationSchema = BaseNotificationSchema.extend({
export const NotificationSchema = ClientNotificationSchema.or( export const NotificationSchema = ClientNotificationSchema.or(
StdErrNotificationSchema, StdErrNotificationSchema,
).or(ServerNotificationSchema); );
export type StdErrNotification = z.infer<typeof StdErrNotificationSchema>; export type StdErrNotification = z.infer<typeof StdErrNotificationSchema>;
export type Notification = z.infer<typeof NotificationSchema>; export type Notification = z.infer<typeof NotificationSchema>;

12
client/src/test.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
/// <reference types="vitest/globals" />
/// <reference types="@testing-library/jest-dom" />
import "@testing-library/jest-dom";
declare global {
namespace Vi {
interface JestAssertion<T = any> extends jest.Matchers<void, T> {}
}
}
export {};

View File

@@ -1,27 +0,0 @@
import { escapeUnicode } from "../escapeUnicode";
describe("escapeUnicode", () => {
it("should escape Unicode characters in a string", () => {
const input = { text: "你好世界" };
const expected = '{\n "text": "\\\\u4f60\\\\u597d\\\\u4e16\\\\u754c"\n}';
expect(escapeUnicode(input)).toBe(expected);
});
it("should handle empty strings", () => {
const input = { text: "" };
const expected = '{\n "text": ""\n}';
expect(escapeUnicode(input)).toBe(expected);
});
it("should handle null and undefined values", () => {
const input = { text: null, value: undefined };
const expected = '{\n "text": null\n}';
expect(escapeUnicode(input)).toBe(expected);
});
it("should handle numbers and booleans", () => {
const input = { number: 123, boolean: true };
const expected = '{\n "number": 123,\n "boolean": true\n}';
expect(escapeUnicode(input)).toBe(expected);
});
});

View File

@@ -1,180 +0,0 @@
import { updateValueAtPath, getValueAtPath } from "../jsonPathUtils";
import { JsonValue } from "../../components/DynamicJsonForm";
describe("updateValueAtPath", () => {
// Basic functionality tests
test("returns the new value when path is empty", () => {
expect(updateValueAtPath({ foo: "bar" }, [], "newValue")).toBe("newValue");
});
test("initializes an empty object when input is null/undefined and path starts with a string", () => {
expect(updateValueAtPath(null as any, ["foo"], "bar")).toEqual({
foo: "bar",
});
expect(updateValueAtPath(undefined as any, ["foo"], "bar")).toEqual({
foo: "bar",
});
});
test("initializes an empty array when input is null/undefined and path starts with a number", () => {
expect(updateValueAtPath(null as any, ["0"], "bar")).toEqual(["bar"]);
expect(updateValueAtPath(undefined as any, ["0"], "bar")).toEqual(["bar"]);
});
// Object update tests
test("updates a simple object property", () => {
const obj = { name: "John", age: 30 };
expect(updateValueAtPath(obj, ["age"], 31)).toEqual({
name: "John",
age: 31,
});
});
test("updates a nested object property", () => {
const obj = { user: { name: "John", address: { city: "New York" } } };
expect(
updateValueAtPath(obj, ["user", "address", "city"], "Boston"),
).toEqual({ user: { name: "John", address: { city: "Boston" } } });
});
test("creates missing object properties", () => {
const obj = { user: { name: "John" } };
expect(
updateValueAtPath(obj, ["user", "address", "city"], "Boston"),
).toEqual({ user: { name: "John", address: { city: "Boston" } } });
});
// Array update tests
test("updates an array item", () => {
const arr = [1, 2, 3, 4];
expect(updateValueAtPath(arr, ["2"], 5)).toEqual([1, 2, 5, 4]);
});
test("extends an array when index is out of bounds", () => {
const arr = [1, 2, 3];
const result = updateValueAtPath(arr, ["5"], "new") as JsonValue[];
// Check overall array structure
expect(result).toEqual([1, 2, 3, null, null, "new"]);
// Explicitly verify that indices 3 and 4 contain null, not undefined
expect(result[3]).toBe(null);
expect(result[4]).toBe(null);
// Verify these aren't "holes" in the array (important distinction)
expect(3 in result).toBe(true);
expect(4 in result).toBe(true);
// Verify the array has the correct length
expect(result.length).toBe(6);
// Verify the array doesn't have holes by checking every index exists
expect(result.every((_, index: number) => index in result)).toBe(true);
});
test("updates a nested array item", () => {
const obj = { users: [{ name: "John" }, { name: "Jane" }] };
expect(updateValueAtPath(obj, ["users", "1", "name"], "Janet")).toEqual({
users: [{ name: "John" }, { name: "Janet" }],
});
});
// Error handling tests
test("returns original value when trying to update a primitive with a path", () => {
const spy = jest.spyOn(console, "error").mockImplementation();
const result = updateValueAtPath("string", ["foo"], "bar");
expect(result).toBe("string");
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test("returns original array when index is invalid", () => {
const spy = jest.spyOn(console, "error").mockImplementation();
const arr = [1, 2, 3];
expect(updateValueAtPath(arr, ["invalid"], 4)).toEqual(arr);
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test("returns original array when index is negative", () => {
const spy = jest.spyOn(console, "error").mockImplementation();
const arr = [1, 2, 3];
expect(updateValueAtPath(arr, ["-1"], 4)).toEqual(arr);
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test("handles sparse arrays correctly by filling holes with null", () => {
// Create a sparse array by deleting an element
const sparseArr = [1, 2, 3];
delete sparseArr[1]; // Now sparseArr is [1, <1 empty item>, 3]
// Update a value beyond the array length
const result = updateValueAtPath(sparseArr, ["5"], "new") as JsonValue[];
// Check overall array structure
expect(result).toEqual([1, null, 3, null, null, "new"]);
// Explicitly verify that index 1 (the hole) contains null, not undefined
expect(result[1]).toBe(null);
// Verify this isn't a hole in the array
expect(1 in result).toBe(true);
// Verify all indices contain null (not undefined)
expect(result[1]).not.toBe(undefined);
expect(result[3]).toBe(null);
expect(result[4]).toBe(null);
});
});
describe("getValueAtPath", () => {
test("returns the original value when path is empty", () => {
const obj = { foo: "bar" };
expect(getValueAtPath(obj, [])).toBe(obj);
});
test("returns the value at a simple path", () => {
const obj = { name: "John", age: 30 };
expect(getValueAtPath(obj, ["name"])).toBe("John");
});
test("returns the value at a nested path", () => {
const obj = { user: { name: "John", address: { city: "New York" } } };
expect(getValueAtPath(obj, ["user", "address", "city"])).toBe("New York");
});
test("returns default value when path does not exist", () => {
const obj = { user: { name: "John" } };
expect(getValueAtPath(obj, ["user", "address", "city"], "Unknown")).toBe(
"Unknown",
);
});
test("returns default value when input is null/undefined", () => {
expect(getValueAtPath(null as any, ["foo"], "default")).toBe("default");
expect(getValueAtPath(undefined as any, ["foo"], "default")).toBe(
"default",
);
});
test("handles array indices correctly", () => {
const arr = ["a", "b", "c"];
expect(getValueAtPath(arr, ["1"])).toBe("b");
});
test("returns default value for out of bounds array indices", () => {
const arr = ["a", "b", "c"];
expect(getValueAtPath(arr, ["5"], "default")).toBe("default");
});
test("returns default value for invalid array indices", () => {
const arr = ["a", "b", "c"];
expect(getValueAtPath(arr, ["invalid"], "default")).toBe("default");
});
test("navigates through mixed object and array paths", () => {
const obj = { users: [{ name: "John" }, { name: "Jane" }] };
expect(getValueAtPath(obj, ["users", "1", "name"])).toBe("Jane");
});
});

View File

@@ -1,139 +0,0 @@
import { generateDefaultValue, formatFieldLabel } from "../schemaUtils";
import { JsonSchemaType } from "../../components/DynamicJsonForm";
describe("generateDefaultValue", () => {
test("generates default string", () => {
expect(generateDefaultValue({ type: "string", required: true })).toBe("");
});
test("generates default number", () => {
expect(generateDefaultValue({ type: "number", required: true })).toBe(0);
});
test("generates default integer", () => {
expect(generateDefaultValue({ type: "integer", required: true })).toBe(0);
});
test("generates default boolean", () => {
expect(generateDefaultValue({ type: "boolean", required: true })).toBe(
false,
);
});
test("generates default array", () => {
expect(generateDefaultValue({ type: "array", required: true })).toEqual([]);
});
test("generates default empty object", () => {
expect(generateDefaultValue({ type: "object", required: true })).toEqual(
{},
);
});
test("generates default null for unknown types", () => {
// @ts-expect-error Testing with invalid type
expect(generateDefaultValue({ type: "unknown", required: true })).toBe(
null,
);
});
test("generates empty array for non-required array", () => {
expect(generateDefaultValue({ type: "array", required: false })).toEqual(
[],
);
});
test("generates empty object for non-required object", () => {
expect(generateDefaultValue({ type: "object", required: false })).toEqual(
{},
);
});
test("generates null for non-required primitive types", () => {
expect(generateDefaultValue({ type: "string", required: false })).toBe(
null,
);
expect(generateDefaultValue({ type: "number", required: false })).toBe(
null,
);
expect(generateDefaultValue({ type: "boolean", required: false })).toBe(
null,
);
});
test("generates object with properties", () => {
const schema: JsonSchemaType = {
type: "object",
required: true,
properties: {
name: { type: "string", required: true },
age: { type: "number", required: true },
isActive: { type: "boolean", required: true },
},
};
expect(generateDefaultValue(schema)).toEqual({
name: "",
age: 0,
isActive: false,
});
});
test("handles nested objects", () => {
const schema: JsonSchemaType = {
type: "object",
required: true,
properties: {
user: {
type: "object",
required: true,
properties: {
name: { type: "string", required: true },
address: {
type: "object",
required: true,
properties: {
city: { type: "string", required: true },
},
},
},
},
},
};
expect(generateDefaultValue(schema)).toEqual({
user: {
name: "",
address: {
city: "",
},
},
});
});
test("uses schema default value when provided", () => {
expect(generateDefaultValue({ type: "string", default: "test" })).toBe(
"test",
);
});
});
describe("formatFieldLabel", () => {
test("formats camelCase", () => {
expect(formatFieldLabel("firstName")).toBe("First Name");
});
test("formats snake_case", () => {
expect(formatFieldLabel("first_name")).toBe("First name");
});
test("formats single word", () => {
expect(formatFieldLabel("name")).toBe("Name");
});
test("formats mixed case with underscores", () => {
expect(formatFieldLabel("user_firstName")).toBe("User first Name");
});
test("handles empty string", () => {
expect(formatFieldLabel("")).toBe("");
});
});

View File

@@ -1,16 +0,0 @@
// Utility function to escape Unicode characters
export function escapeUnicode(obj: unknown): string {
return JSON.stringify(
obj,
(_key: string, value) => {
if (typeof value === "string") {
// Replace non-ASCII characters with their Unicode escape sequences
return value.replace(/[^\0-\x7F]/g, (char) => {
return "\\u" + ("0000" + char.charCodeAt(0).toString(16)).slice(-4);
});
}
return value;
},
2,
);
}

View File

@@ -1,149 +0,0 @@
import { JsonValue } from "../components/DynamicJsonForm";
export type JsonObject = { [key: string]: JsonValue };
/**
* Updates a value at a specific path in a nested JSON structure
* @param obj The original JSON value
* @param path Array of keys/indices representing the path to the value
* @param value The new value to set
* @returns A new JSON value with the updated path
*/
export function updateValueAtPath(
obj: JsonValue,
path: string[],
value: JsonValue,
): JsonValue {
if (path.length === 0) return value;
if (obj === null || obj === undefined) {
obj = !isNaN(Number(path[0])) ? [] : {};
}
if (Array.isArray(obj)) {
return updateArray(obj, path, value);
} else if (typeof obj === "object" && obj !== null) {
return updateObject(obj as JsonObject, path, value);
} else {
console.error(
`Cannot update path ${path.join(".")} in non-object/array value:`,
obj,
);
return obj;
}
}
/**
* Updates an array at a specific path
*/
function updateArray(
array: JsonValue[],
path: string[],
value: JsonValue,
): JsonValue[] {
const [index, ...restPath] = path;
const arrayIndex = Number(index);
if (isNaN(arrayIndex)) {
console.error(`Invalid array index: ${index}`);
return array;
}
if (arrayIndex < 0) {
console.error(`Array index out of bounds: ${arrayIndex} < 0`);
return array;
}
let newArray: JsonValue[] = [];
for (let i = 0; i < array.length; i++) {
newArray[i] = i in array ? array[i] : null;
}
if (arrayIndex >= newArray.length) {
const extendedArray: JsonValue[] = new Array(arrayIndex).fill(null);
// Copy over the existing elements (now guaranteed to be dense)
for (let i = 0; i < newArray.length; i++) {
extendedArray[i] = newArray[i];
}
newArray = extendedArray;
}
if (restPath.length === 0) {
newArray[arrayIndex] = value;
} else {
newArray[arrayIndex] = updateValueAtPath(
newArray[arrayIndex],
restPath,
value,
);
}
return newArray;
}
/**
* Updates an object at a specific path
*/
function updateObject(
obj: JsonObject,
path: string[],
value: JsonValue,
): JsonObject {
const [key, ...restPath] = path;
// Validate object key
if (typeof key !== "string") {
console.error(`Invalid object key: ${key}`);
return obj;
}
const newObj = { ...obj };
if (restPath.length === 0) {
newObj[key] = value;
} else {
// Ensure key exists
if (!(key in newObj)) {
newObj[key] = {};
}
newObj[key] = updateValueAtPath(newObj[key], restPath, value);
}
return newObj;
}
/**
* Gets a value at a specific path in a nested JSON structure
* @param obj The JSON value to traverse
* @param path Array of keys/indices representing the path to the value
* @param defaultValue Value to return if path doesn't exist
* @returns The value at the path, or defaultValue if not found
*/
export function getValueAtPath(
obj: JsonValue,
path: string[],
defaultValue: JsonValue = null,
): JsonValue {
if (path.length === 0) return obj;
const [first, ...rest] = path;
if (obj === null || obj === undefined) {
return defaultValue;
}
if (Array.isArray(obj)) {
const index = Number(first);
if (isNaN(index) || index < 0 || index >= obj.length) {
return defaultValue;
}
return getValueAtPath(obj[index], rest, defaultValue);
}
if (typeof obj === "object" && obj !== null) {
if (!(first in obj)) {
return defaultValue;
}
return getValueAtPath((obj as JsonObject)[first], rest, defaultValue);
}
return defaultValue;
}

View File

@@ -1,57 +0,0 @@
import { JsonValue, JsonSchemaType } from "../components/DynamicJsonForm";
import { JsonObject } from "./jsonPathUtils";
/**
* Generates a default value based on a JSON schema type
* @param schema The JSON schema definition
* @returns A default value matching the schema type, or null for non-required fields
*/
export function generateDefaultValue(schema: JsonSchemaType): JsonValue {
if ("default" in schema) {
return schema.default;
}
if (!schema.required) {
if (schema.type === "array") return [];
if (schema.type === "object") return {};
return null;
}
switch (schema.type) {
case "string":
return "";
case "number":
case "integer":
return 0;
case "boolean":
return false;
case "array":
return [];
case "object": {
if (!schema.properties) return {};
const obj: JsonObject = {};
Object.entries(schema.properties)
.filter(([, prop]) => prop.required)
.forEach(([key, prop]) => {
const value = generateDefaultValue(prop);
obj[key] = value;
});
return obj;
}
default:
return null;
}
}
/**
* Formats a field key into a human-readable label
* @param key The field key to format
* @returns A formatted label string
*/
export function formatFieldLabel(key: string): string {
return key
.replace(/([A-Z])/g, " $1") // Insert space before capital letters
.replace(/_/g, " ") // Replace underscores with spaces
.replace(/^\w/, (c) => c.toUpperCase()); // Capitalize first letter
}

View File

@@ -0,0 +1,6 @@
/// <reference types="vitest/globals" />
/// <reference types="@testing-library/jest-dom" />
import "@testing-library/jest-dom/vitest";
// Add any additional test setup, custom matchers, or global mocks here
// This file runs before each test file

View File

@@ -4,6 +4,7 @@
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
}, },
"types": ["vitest/globals", "@testing-library/jest-dom"],
"target": "ES2020", "target": "ES2020",
"useDefineForClassFields": true, "useDefineForClassFields": true,
@@ -26,5 +27,5 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"resolveJsonModule": true "resolveJsonModule": true
}, },
"include": ["src"] "include": ["src", "test"]
} }

View File

@@ -1,10 +0,0 @@
{
"extends": "./tsconfig.app.json",
"compilerOptions": {
"jsx": "react-jsx",
"esModuleInterop": true,
"module": "ESNext",
"moduleResolution": "node"
},
"include": ["src"]
}

View File

@@ -2,7 +2,8 @@
"files": [], "files": [],
"references": [ "references": [
{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" } { "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.test.json" }
], ],
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",

View File

@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.app.json",
"compilerOptions": {
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src/**/*.test.tsx", "src/**/*.test.ts", "test/**/*.ts"]
}

View File

@@ -1,13 +1,10 @@
import react from "@vitejs/plugin-react";
import path from "path"; import path from "path";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: {
host: true,
},
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),

20
client/vitest.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./test/setupTests.ts"],
typecheck: {
tsconfig: "./tsconfig.test.json",
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});

4608
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "@modelcontextprotocol/inspector", "name": "@modelcontextprotocol/inspector",
"version": "0.7.0", "version": "0.3.0",
"description": "Model Context Protocol inspector", "description": "Model Context Protocol inspector",
"license": "MIT", "license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)", "author": "Anthropic, PBC (https://anthropic.com)",
@@ -22,7 +22,6 @@
], ],
"scripts": { "scripts": {
"dev": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev\"", "dev": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev\"",
"dev:windows": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev:windows",
"build-server": "cd server && npm run build", "build-server": "cd server && npm run build",
"build-client": "cd client && npm run build", "build-client": "cd client && npm run build",
"build": "npm run build-server && npm run build-client", "build": "npm run build-server && npm run build-client",
@@ -34,15 +33,14 @@
"publish-all": "npm publish --workspaces --access public && npm publish --access public" "publish-all": "npm publish --workspaces --access public && npm publish --access public"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/inspector-client": "^0.7.0", "@modelcontextprotocol/inspector-client": "0.3.0",
"@modelcontextprotocol/inspector-server": "^0.7.0", "@modelcontextprotocol/inspector-server": "0.3.0",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"shell-quote": "^1.8.2", "shell-quote": "^1.8.2",
"spawn-rx": "^5.1.2", "spawn-rx": "^5.1.0",
"ts-node": "^10.9.2" "ts-node": "^10.9.2"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "^22.7.5", "@types/node": "^22.7.5",
"@types/shell-quote": "^1.7.5", "@types/shell-quote": "^1.7.5",
"prettier": "3.3.3" "prettier": "3.3.3"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@modelcontextprotocol/inspector-server", "name": "@modelcontextprotocol/inspector-server",
"version": "0.7.0", "version": "0.3.0",
"description": "Server-side application for the Model Context Protocol inspector", "description": "Server-side application for the Model Context Protocol inspector",
"license": "MIT", "license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)", "author": "Anthropic, PBC (https://anthropic.com)",
@@ -16,19 +16,20 @@
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"start": "node build/index.js", "start": "node build/index.js",
"dev": "tsx watch --clear-screen=false src/index.ts", "dev": "tsx watch --clear-screen=false src/index.ts"
"dev:windows": "tsx watch --clear-screen=false src/index.ts < NUL"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/eventsource": "^1.1.15",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/ws": "^8.5.12", "@types/ws": "^8.5.12",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5.6.2" "typescript": "^5.6.2"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1", "@modelcontextprotocol/sdk": "^1.0.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"eventsource": "^2.0.2",
"express": "^4.21.0", "express": "^4.21.0",
"ws": "^8.18.0", "ws": "^8.18.0",
"zod": "^3.23.8" "zod": "^3.23.8"

View File

@@ -1,29 +1,29 @@
#!/usr/bin/env node #!/usr/bin/env node
import cors from "cors"; import cors from "cors";
import EventSource from "eventsource";
import { parseArgs } from "node:util"; import { parseArgs } from "node:util";
import { parse as shellParseArgs } from "shell-quote"; import { parse as shellParseArgs } from "shell-quote";
import { import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
SSEClientTransport,
SseError,
} from "@modelcontextprotocol/sdk/client/sse.js";
import { import {
StdioClientTransport, StdioClientTransport,
getDefaultEnvironment, getDefaultEnvironment,
} from "@modelcontextprotocol/sdk/client/stdio.js"; } from "@modelcontextprotocol/sdk/client/stdio.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import express from "express"; import express from "express";
import { findActualExecutable } from "spawn-rx";
import mcpProxy from "./mcpProxy.js"; import mcpProxy from "./mcpProxy.js";
import { findActualExecutable } from "spawn-rx";
const SSE_HEADERS_PASSTHROUGH = ["authorization"];
const defaultEnvironment = { const defaultEnvironment = {
...getDefaultEnvironment(), ...getDefaultEnvironment(),
...(process.env.MCP_ENV_VARS ? JSON.parse(process.env.MCP_ENV_VARS) : {}), ...(process.env.MCP_ENV_VARS ? JSON.parse(process.env.MCP_ENV_VARS) : {}),
}; };
// Polyfill EventSource for an SSE client in Node.js
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(global as any).EventSource = EventSource;
const { values } = parseArgs({ const { values } = parseArgs({
args: process.argv.slice(2), args: process.argv.slice(2),
options: { options: {
@@ -37,8 +37,7 @@ app.use(cors());
let webAppTransports: SSEServerTransport[] = []; let webAppTransports: SSEServerTransport[] = [];
const createTransport = async (req: express.Request) => { const createTransport = async (query: express.Request["query"]) => {
const query = req.query;
console.log("Query parameters:", query); console.log("Query parameters:", query);
const transportType = query.transportType as string; const transportType = query.transportType as string;
@@ -66,28 +65,9 @@ const createTransport = async (req: express.Request) => {
return transport; return transport;
} else if (transportType === "sse") { } else if (transportType === "sse") {
const url = query.url as string; const url = query.url as string;
const headers: HeadersInit = { console.log(`SSE transport: url=${url}`);
Accept: "text/event-stream",
};
for (const key of SSE_HEADERS_PASSTHROUGH) {
if (req.headers[key] === undefined) {
continue;
}
const value = req.headers[key]; const transport = new SSEClientTransport(new URL(url));
headers[key] = Array.isArray(value) ? value[value.length - 1] : value;
}
console.log(`SSE transport: url=${url}, headers=${Object.keys(headers)}`);
const transport = new SSEClientTransport(new URL(url), {
eventSourceInit: {
fetch: (url, init) => fetch(url, { ...init, headers }),
},
requestInit: {
headers,
},
});
await transport.start(); await transport.start();
console.log("Connected to SSE transport"); console.log("Connected to SSE transport");
@@ -102,21 +82,7 @@ app.get("/sse", async (req, res) => {
try { try {
console.log("New SSE connection"); console.log("New SSE connection");
let backingServerTransport; const backingServerTransport = await createTransport(req.query);
try {
backingServerTransport = await createTransport(req);
} catch (error) {
if (error instanceof SseError && error.code === 401) {
console.error(
"Received 401 Unauthorized from MCP server:",
error.message,
);
res.status(401).json(error);
return;
}
throw error;
}
console.log("Connected MCP client to backing server transport"); console.log("Connected MCP client to backing server transport");
@@ -143,6 +109,9 @@ app.get("/sse", async (req, res) => {
mcpProxy({ mcpProxy({
transportToClient: webAppTransport, transportToClient: webAppTransport,
transportToServer: backingServerTransport, transportToServer: backingServerTransport,
onerror: (error) => {
console.error(error);
},
}); });
console.log("Set up MCP proxy"); console.log("Set up MCP proxy");
@@ -183,16 +152,4 @@ app.get("/config", (req, res) => {
}); });
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {});
try {
const server = app.listen(PORT);
server.on("listening", () => {
const addr = server.address();
const port = typeof addr === "string" ? addr : addr?.port;
console.log(`Proxy server listening on port ${port}`);
});
} catch (error) {
console.error("Failed to start server:", error);
process.exit(1);
}

View File

@@ -1,29 +1,23 @@
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
function onClientError(error: Error) {
console.error("Error from inspector client:", error);
}
function onServerError(error: Error) {
console.error("Error from MCP server:", error);
}
export default function mcpProxy({ export default function mcpProxy({
transportToClient, transportToClient,
transportToServer, transportToServer,
onerror,
}: { }: {
transportToClient: Transport; transportToClient: Transport;
transportToServer: Transport; transportToServer: Transport;
onerror: (error: Error) => void;
}) { }) {
let transportToClientClosed = false; let transportToClientClosed = false;
let transportToServerClosed = false; let transportToServerClosed = false;
transportToClient.onmessage = (message) => { transportToClient.onmessage = (message) => {
transportToServer.send(message).catch(onServerError); transportToServer.send(message).catch(onerror);
}; };
transportToServer.onmessage = (message) => { transportToServer.onmessage = (message) => {
transportToClient.send(message).catch(onClientError); transportToClient.send(message).catch(onerror);
}; };
transportToClient.onclose = () => { transportToClient.onclose = () => {
@@ -32,7 +26,7 @@ export default function mcpProxy({
} }
transportToClientClosed = true; transportToClientClosed = true;
transportToServer.close().catch(onServerError); transportToServer.close().catch(onerror);
}; };
transportToServer.onclose = () => { transportToServer.onclose = () => {
@@ -40,9 +34,10 @@ export default function mcpProxy({
return; return;
} }
transportToServerClosed = true; transportToServerClosed = true;
transportToClient.close().catch(onClientError);
transportToClient.close().catch(onerror);
}; };
transportToClient.onerror = onClientError; transportToClient.onerror = onerror;
transportToServer.onerror = onServerError; transportToServer.onerror = onerror;
} }