Compare commits

..

5 Commits

Author SHA1 Message Date
Ashwin Bhat
97f29b32cc chore: add coverage directory to gitignore 2024-12-20 15:51:43 -08:00
Ashwin Bhat
586c497740 style: run prettier on src directory 2024-12-20 15:51:41 -08:00
Ashwin Bhat
c2c2043d05 test: add test coverage reporting
- Add @vitest/coverage-v8 for test coverage reporting
- Current coverage: 65.59% statements, 83.83% branches, 42.7% functions
- Components like ConsoleTab, ListPane, PingTab, RootsTab, SamplingTab at 100%
- Identified areas needing more coverage: ToolsTab, Sidebar, hooks
2024-12-20 15:51:38 -08:00
Ashwin Bhat
579dd42c46 test: add tests for App component
- Add comprehensive tests for App.tsx
- Fix test setup to handle window.matchMedia and URL params
- Wrap state updates in act()
- Use more specific selectors for finding elements
- Fix sampling tab test to properly simulate pending requests
2024-12-20 15:36:57 -08:00
Ashwin Bhat
1ab1aba528 test: move component tests to __tests__ directory 2024-12-20 15:36:54 -08:00
45 changed files with 2471 additions and 3122 deletions

View File

@@ -14,9 +14,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Check formatting
run: npx prettier --check .
- uses: actions/setup-node@v4
with:
node-version: 18

1
.gitignore vendored
View File

@@ -2,5 +2,6 @@
node_modules
server/build
client/dist
client/coverage
client/tsconfig.app.tsbuildinfo
client/tsconfig.node.tsbuildinfo

View File

@@ -1,4 +1,2 @@
packages
server/build
CODE_OF_CONDUCT.md
SECURITY.md

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,37 +11,23 @@ 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`:
```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 also pass arguments along which will get passed as arguments to your MCP server:
```bash
# Pass arguments only
npx @modelcontextprotocol/inspector build/index.js arg1 arg2
# Pass environment variables only
npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/index.js
# Pass both environment variables and arguments
npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/index.js arg1 arg2
# Use -- to separate inspector flags from server arguments
npx @modelcontextprotocol/inspector -e KEY=$VALUE -- node build/index.js -e server-flag
```
npx @modelcontextprotocol/inspector build/index.js arg1 arg2 ...
```
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
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).
### 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
If you're working on the inspector itself:
@@ -52,13 +38,6 @@ Development mode:
npm run dev
```
> **Note for Windows users:**
> On Windows, use the following command instead:
>
> ```bash
> npm run dev:windows
> ```
Production mode:
```bash

View File

@@ -11,32 +11,8 @@ function delay(ms) {
}
async function main() {
// Parse command line arguments
const args = process.argv.slice(2);
const envVars = {};
const mcpServerArgs = [];
let command = null;
let parsingFlags = true;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (parsingFlags && arg === "--") {
parsingFlags = false;
continue;
}
if (parsingFlags && arg === "-e" && i + 1 < args.length) {
const [key, value] = args[++i].split("=");
if (key && value) {
envVars[key] = value;
}
} else if (!command) {
command = arg;
} else {
mcpServerArgs.push(arg);
}
}
// Get command line arguments
const [, , command, ...mcpServerArgs] = process.argv;
const inspectorServerPath = resolve(
__dirname,
@@ -76,11 +52,7 @@ async function main() {
...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []),
],
{
env: {
...process.env,
PORT: SERVER_PORT,
MCP_ENV_VARS: JSON.stringify(envVars),
},
env: { ...process.env, PORT: SERVER_PORT },
signal: abort.signal,
echoOutput: true,
},

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-client",
"version": "0.6.0",
"version": "0.3.0",
"description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@@ -18,28 +18,22 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1",
"@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@modelcontextprotocol/sdk": "^1.0.3",
"@radix-ui/react-icons": "^1.3.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-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.1",
"@types/prismjs": "^1.26.5",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
"lucide-react": "^0.447.0",
"prismjs": "^1.29.0",
"pkce-challenge": "^4.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-simple-code-editor": "^0.14.1",
"react-toastify": "^10.0.6",
"serve-handler": "^6.1.6",
"tailwind-merge": "^2.5.3",
@@ -48,20 +42,25 @@
},
"devDependencies": {
"@eslint/js": "^9.11.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@types/node": "^22.7.5",
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@types/serve-handler": "^6.1.4",
"@vitejs/plugin-react": "^4.3.2",
"@vitest/coverage-v8": "^2.1.8",
"autoprefixer": "^10.4.20",
"eslint": "^9.11.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.12",
"globals": "^15.9.0",
"jsdom": "^25.0.1",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5.5.3",
"typescript-eslint": "^8.7.0",
"vite": "^5.4.8"
"vite": "^5.4.8",
"vitest": "^2.1.8"
}
}

View File

@@ -1,3 +1,5 @@
import { useDraggablePane } from "./lib/hooks/useDraggablePane";
import { useConnection } from "./lib/hooks/useConnection";
import {
ClientRequest,
CompatibilityCallToolResult,
@@ -8,17 +10,15 @@ import {
ListPromptsResultSchema,
ListResourcesResultSchema,
ListResourceTemplatesResultSchema,
ListToolsResultSchema,
ReadResourceResultSchema,
ListToolsResultSchema,
Resource,
ResourceTemplate,
Root,
ServerNotification,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import React, { Suspense, useEffect, useRef, useState } from "react";
import { useConnection } from "./lib/hooks/useConnection";
import { useDraggablePane } from "./lib/hooks/useDraggablePane";
import { useEffect, useRef, useState } from "react";
import { StdErrNotification } from "./lib/notificationTypes";
@@ -32,7 +32,6 @@ import {
MessageSquare,
} from "lucide-react";
import { toast } from "react-toastify";
import { z } from "zod";
import "./App.css";
import ConsoleTab from "./components/ConsoleTab";
@@ -50,17 +49,6 @@ const PROXY_PORT = params.get("proxyPort") ?? "3000";
const PROXY_SERVER_URL = `http://localhost:${PROXY_PORT}`;
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 [resourceTemplates, setResourceTemplates] = useState<
ResourceTemplate[]
@@ -83,23 +71,14 @@ const App = () => {
return localStorage.getItem("lastArgs") || "";
});
const [sseUrl, setSseUrl] = useState<string>(() => {
return localStorage.getItem("lastSseUrl") || "http://localhost:3001/sse";
});
const [transportType, setTransportType] = useState<"stdio" | "sse">(() => {
return (
(localStorage.getItem("lastTransportType") as "stdio" | "sse") || "stdio"
);
});
const [sseUrl, setSseUrl] = useState<string>("http://localhost:3001/sse");
const [transportType, setTransportType] = useState<"stdio" | "sse">("stdio");
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
const [stdErrNotifications, setStdErrNotifications] = useState<
StdErrNotification[]
>([]);
const [roots, setRoots] = useState<Root[]>([]);
const [env, setEnv] = useState<Record<string, string>>({});
const [bearerToken, setBearerToken] = useState<string>(() => {
return localStorage.getItem("lastBearerToken") || "";
});
const [pendingSampleRequests, setPendingSampleRequests] = useState<
Array<
@@ -131,10 +110,6 @@ const App = () => {
const [selectedResource, setSelectedResource] = useState<Resource | null>(
null,
);
const [resourceSubscriptions, setResourceSubscriptions] = useState<
Set<string>
>(new Set<string>());
const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
const [selectedTool, setSelectedTool] = useState<Tool | null>(null);
const [nextResourceCursor, setNextResourceCursor] = useState<
@@ -158,8 +133,6 @@ const App = () => {
requestHistory,
makeRequest: makeConnectionRequest,
sendNotification,
handleCompletion,
completionsSupported,
connect: connectMcpServer,
} = useConnection({
transportType,
@@ -167,7 +140,6 @@ const App = () => {
args,
sseUrl,
env,
bearerToken,
proxyServerUrl: PROXY_SERVER_URL,
onNotification: (notification) => {
setNotifications((prev) => [...prev, notification as ServerNotification]);
@@ -187,6 +159,29 @@ const App = () => {
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(() => {
localStorage.setItem("lastCommand", command);
}, [command]);
@@ -195,35 +190,6 @@ const App = () => {
localStorage.setItem("lastArgs", 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(() => {
fetch(`${PROXY_SERVER_URL}/config`)
.then((response) => response.json())
@@ -255,29 +221,6 @@ const App = () => {
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 response = await makeRequest(
{
@@ -320,38 +263,6 @@ const App = () => {
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 response = await makeRequest(
{
@@ -426,8 +337,6 @@ const App = () => {
setSseUrl={setSseUrl}
env={env}
setEnv={setEnv}
bearerToken={bearerToken}
setBearerToken={setBearerToken}
onConnect={connectMcpServer}
stdErrNotifications={stdErrNotifications}
/>
@@ -531,20 +440,6 @@ const App = () => {
clearError("resources");
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}
nextCursor={nextResourceCursor}
nextTemplateCursor={nextResourceTemplateCursor}
@@ -569,8 +464,6 @@ const App = () => {
clearError("prompts");
setSelectedPrompt(prompt);
}}
handleCompletion={handleCompletion}
completionsSupported={completionsSupported}
promptContent={promptContent}
nextCursor={nextPromptCursor}
error={errors.prompts}

View File

@@ -0,0 +1,220 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import App from "../App";
import { useConnection } from "../lib/hooks/useConnection";
import { useDraggablePane } from "../lib/hooks/useDraggablePane";
// Mock URL params
const mockURLSearchParams = vi.fn();
vi.stubGlobal("URLSearchParams", mockURLSearchParams);
// Mock the hooks
vi.mock("../lib/hooks/useConnection", () => ({
useConnection: vi.fn(),
}));
vi.mock("../lib/hooks/useDraggablePane", () => ({
useDraggablePane: vi.fn(),
}));
// Mock fetch for config
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe("App", () => {
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks();
// Mock URL params
mockURLSearchParams.mockReturnValue({
get: () => "3000",
});
// Mock fetch response
mockFetch.mockResolvedValue({
json: () =>
Promise.resolve({
defaultEnvironment: {},
defaultCommand: "test-command",
defaultArgs: "--test",
}),
});
// Mock useConnection hook
const mockUseConnection = useConnection as jest.Mock;
mockUseConnection.mockReturnValue({
connectionStatus: "disconnected",
serverCapabilities: null,
mcpClient: null,
requestHistory: [],
makeRequest: vi.fn(),
sendNotification: vi.fn(),
connect: vi.fn(),
});
// Mock useDraggablePane hook
const mockUseDraggablePane = useDraggablePane as jest.Mock;
mockUseDraggablePane.mockReturnValue({
height: 300,
handleDragStart: vi.fn(),
});
});
it("renders initial disconnected state", async () => {
await act(async () => {
render(<App />);
});
expect(
screen.getByText("Connect to an MCP server to start inspecting"),
).toBeInTheDocument();
});
it("loads config on mount", async () => {
await act(async () => {
render(<App />);
});
expect(mockFetch).toHaveBeenCalledWith("http://localhost:3000/config");
});
it("shows connected interface when mcpClient is available", async () => {
const mockUseConnection = useConnection as jest.Mock;
mockUseConnection.mockReturnValue({
connectionStatus: "connected",
serverCapabilities: {
resources: true,
prompts: true,
tools: true,
},
mcpClient: {},
requestHistory: [],
makeRequest: vi.fn(),
sendNotification: vi.fn(),
connect: vi.fn(),
});
await act(async () => {
render(<App />);
});
// Use more specific selectors
const resourcesTab = screen.getByRole("tab", { name: /resources/i });
const promptsTab = screen.getByRole("tab", { name: /prompts/i });
const toolsTab = screen.getByRole("tab", { name: /tools/i });
expect(resourcesTab).toBeInTheDocument();
expect(promptsTab).toBeInTheDocument();
expect(toolsTab).toBeInTheDocument();
});
it("disables tabs based on server capabilities", async () => {
const mockUseConnection = useConnection as jest.Mock;
mockUseConnection.mockReturnValue({
connectionStatus: "connected",
serverCapabilities: {
resources: false,
prompts: true,
tools: false,
},
mcpClient: {},
requestHistory: [],
makeRequest: vi.fn(),
sendNotification: vi.fn(),
connect: vi.fn(),
});
await act(async () => {
render(<App />);
});
// Resources tab should be disabled
const resourcesTab = screen.getByRole("tab", { name: /resources/i });
expect(resourcesTab).toHaveAttribute("disabled");
// Prompts tab should be enabled
const promptsTab = screen.getByRole("tab", { name: /prompts/i });
expect(promptsTab).not.toHaveAttribute("disabled");
// Tools tab should be disabled
const toolsTab = screen.getByRole("tab", { name: /tools/i });
expect(toolsTab).toHaveAttribute("disabled");
});
it("shows notification count in sampling tab", async () => {
const mockUseConnection = useConnection as jest.Mock;
mockUseConnection.mockReturnValue({
connectionStatus: "connected",
serverCapabilities: { sampling: true },
mcpClient: {},
requestHistory: [],
makeRequest: vi.fn(),
sendNotification: vi.fn(),
connect: vi.fn(),
onPendingRequest: (request, resolve, reject) => {
// Simulate a pending request
setPendingSampleRequests((prev) => [
...prev,
{ id: 1, request, resolve, reject },
]);
},
});
await act(async () => {
render(<App />);
});
// Initially no notification count
const samplingTab = screen.getByRole("tab", { name: /sampling/i });
expect(samplingTab.querySelector(".bg-red-500")).not.toBeInTheDocument();
// Simulate a pending request
await act(async () => {
mockUseConnection.mock.calls[0][0].onPendingRequest(
{ method: "test", params: {} },
() => {},
() => {},
);
});
// Should show notification count
expect(samplingTab.querySelector(".bg-red-500")).toBeInTheDocument();
});
it("persists command and args to localStorage", async () => {
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
await act(async () => {
render(<App />);
});
// Simulate command change
await act(async () => {
const commandInput = screen.getByPlaceholderText(/command/i);
fireEvent.change(commandInput, { target: { value: "new-command" } });
});
expect(setItemSpy).toHaveBeenCalledWith("lastCommand", "new-command");
});
it("shows error message when server has no capabilities", async () => {
const mockUseConnection = useConnection as jest.Mock;
mockUseConnection.mockReturnValue({
connectionStatus: "connected",
serverCapabilities: {},
mcpClient: {},
requestHistory: [],
makeRequest: vi.fn(),
sendNotification: vi.fn(),
connect: vi.fn(),
});
await act(async () => {
render(<App />);
});
expect(
screen.getByText(
"The connected server does not support any MCP capabilities",
),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import HistoryAndNotifications from "../../components/History";
describe("HistoryAndNotifications", () => {
const mockHistory = [
{
request: JSON.stringify({ method: "test1" }),
response: JSON.stringify({ result: "output1" }),
},
{
request: JSON.stringify({ method: "test2" }),
response: JSON.stringify({ result: "output2" }),
},
];
it("renders history items", () => {
render(
<HistoryAndNotifications
requestHistory={mockHistory}
serverNotifications={[]}
/>,
);
const items = screen.getAllByText(/test[12]/, { exact: false });
expect(items).toHaveLength(2);
});
it("expands history item when clicked", () => {
render(
<HistoryAndNotifications
requestHistory={mockHistory}
serverNotifications={[]}
/>,
);
const firstItem = screen.getByText(/test1/, { exact: false });
fireEvent.click(firstItem);
expect(screen.getByText("Request:")).toBeInTheDocument();
expect(screen.getByText(/output1/, { exact: false })).toBeInTheDocument();
});
it("renders and expands server notifications", () => {
const notifications = [
{ method: "notify1", params: { data: "test data 1" } },
{ method: "notify2", params: { data: "test data 2" } },
];
render(
<HistoryAndNotifications
requestHistory={[]}
serverNotifications={notifications}
/>,
);
const items = screen.getAllByText(/notify[12]/, { exact: false });
expect(items).toHaveLength(2);
fireEvent.click(items[0]);
expect(screen.getByText("Details:")).toBeInTheDocument();
expect(screen.getByText(/test data/, { exact: false })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,84 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import ListPane from "../../components/ListPane";
describe("ListPane", () => {
type TestItem = {
id: number;
name: string;
};
const mockItems: TestItem[] = [
{ id: 1, name: "Item 1" },
{ id: 2, name: "Item 2" },
];
const defaultProps = {
items: mockItems,
listItems: vi.fn(),
clearItems: vi.fn(),
setSelectedItem: vi.fn(),
renderItem: (item: TestItem) => (
<>
<span className="flex-1">{item.name}</span>
<span className="text-sm text-gray-500">ID: {item.id}</span>
</>
),
title: "Test Items",
buttonText: "List Items",
};
it("renders title and buttons", () => {
render(<ListPane {...defaultProps} />);
expect(screen.getByText("Test Items")).toBeInTheDocument();
expect(screen.getByText("List Items")).toBeInTheDocument();
expect(screen.getByText("Clear")).toBeInTheDocument();
});
it("renders list of items using renderItem prop", () => {
render(<ListPane {...defaultProps} />);
expect(screen.getByText("Item 1")).toBeInTheDocument();
expect(screen.getByText("Item 2")).toBeInTheDocument();
expect(screen.getByText("ID: 1")).toBeInTheDocument();
expect(screen.getByText("ID: 2")).toBeInTheDocument();
});
it("calls listItems when List Items button is clicked", () => {
const listItems = vi.fn();
render(<ListPane {...defaultProps} listItems={listItems} />);
fireEvent.click(screen.getByText("List Items"));
expect(listItems).toHaveBeenCalled();
});
it("calls clearItems when Clear button is clicked", () => {
const clearItems = vi.fn();
render(<ListPane {...defaultProps} clearItems={clearItems} />);
fireEvent.click(screen.getByText("Clear"));
expect(clearItems).toHaveBeenCalled();
});
it("calls setSelectedItem when an item is clicked", () => {
const setSelectedItem = vi.fn();
render(<ListPane {...defaultProps} setSelectedItem={setSelectedItem} />);
fireEvent.click(screen.getByText("Item 1"));
expect(setSelectedItem).toHaveBeenCalledWith(mockItems[0]);
});
it("disables Clear button when items array is empty", () => {
render(<ListPane {...defaultProps} items={[]} />);
expect(screen.getByText("Clear")).toBeDisabled();
});
it("disables List Items button when isButtonDisabled is true", () => {
render(<ListPane {...defaultProps} isButtonDisabled={true} />);
expect(screen.getByText("List Items")).toBeDisabled();
});
it("enables List Items button when isButtonDisabled is false", () => {
render(<ListPane {...defaultProps} isButtonDisabled={false} />);
expect(screen.getByText("List Items")).not.toBeDisabled();
});
});

View File

@@ -0,0 +1,51 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import PingTab from "../../components/PingTab";
import { Tabs } from "@/components/ui/tabs";
describe("PingTab", () => {
const renderWithTabs = (component: React.ReactElement) => {
return render(<Tabs defaultValue="ping">{component}</Tabs>);
};
it("renders the MEGA PING button", () => {
renderWithTabs(<PingTab onPingClick={() => {}} />);
const button = screen.getByRole("button", { name: /mega ping/i });
expect(button).toBeInTheDocument();
expect(button).toHaveClass(
"bg-gradient-to-r",
"from-purple-500",
"to-pink-500",
);
});
it("includes rocket and explosion emojis", () => {
renderWithTabs(<PingTab onPingClick={() => {}} />);
expect(screen.getByText("🚀")).toBeInTheDocument();
expect(screen.getByText("💥")).toBeInTheDocument();
});
it("calls onPingClick when button is clicked", () => {
const onPingClick = vi.fn();
renderWithTabs(<PingTab onPingClick={onPingClick} />);
fireEvent.click(screen.getByRole("button", { name: /mega ping/i }));
expect(onPingClick).toHaveBeenCalledTimes(1);
});
it("has animation classes for visual feedback", () => {
renderWithTabs(<PingTab onPingClick={() => {}} />);
const button = screen.getByRole("button", { name: /mega ping/i });
expect(button).toHaveClass(
"animate-pulse",
"hover:scale-110",
"transition",
);
});
it("has focus styles for accessibility", () => {
renderWithTabs(<PingTab onPingClick={() => {}} />);
const button = screen.getByRole("button", { name: /mega ping/i });
expect(button).toHaveClass("focus:outline-none", "focus:ring-4");
});
});

View File

@@ -0,0 +1,98 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import PromptsTab from "../../components/PromptsTab";
import type { Prompt } from "../../components/PromptsTab";
import { Tabs } from "@/components/ui/tabs";
describe("PromptsTab", () => {
const mockPrompts: Prompt[] = [
{
name: "test-prompt-1",
description: "Test prompt 1 description",
arguments: [
{ name: "arg1", description: "Argument 1", required: true },
{ name: "arg2", description: "Argument 2" },
],
},
{
name: "test-prompt-2",
description: "Test prompt 2 description",
},
];
const defaultProps = {
prompts: mockPrompts,
listPrompts: vi.fn(),
clearPrompts: vi.fn(),
getPrompt: vi.fn(),
selectedPrompt: null,
setSelectedPrompt: vi.fn(),
promptContent: "",
nextCursor: null,
error: null,
};
const renderWithTabs = (component: React.ReactElement) => {
return render(<Tabs defaultValue="prompts">{component}</Tabs>);
};
it("renders list of prompts", () => {
renderWithTabs(<PromptsTab {...defaultProps} />);
expect(screen.getByText("test-prompt-1")).toBeInTheDocument();
expect(screen.getByText("test-prompt-2")).toBeInTheDocument();
});
it("shows prompt details when selected", () => {
const props = {
...defaultProps,
selectedPrompt: mockPrompts[0],
};
renderWithTabs(<PromptsTab {...props} />);
expect(
screen.getByText("Test prompt 1 description", {
selector: "p.text-sm.text-gray-600",
}),
).toBeInTheDocument();
expect(screen.getByText("arg1")).toBeInTheDocument();
expect(screen.getByText("arg2")).toBeInTheDocument();
});
it("handles argument input", () => {
const getPrompt = vi.fn();
const props = {
...defaultProps,
selectedPrompt: mockPrompts[0],
getPrompt,
};
renderWithTabs(<PromptsTab {...props} />);
const arg1Input = screen.getByPlaceholderText("Enter arg1");
fireEvent.change(arg1Input, { target: { value: "test value" } });
const getPromptButton = screen.getByText("Get Prompt");
fireEvent.click(getPromptButton);
expect(getPrompt).toHaveBeenCalledWith("test-prompt-1", {
arg1: "test value",
});
});
it("shows error message when error prop is provided", () => {
const props = {
...defaultProps,
error: "Test error message",
};
renderWithTabs(<PromptsTab {...props} />);
expect(screen.getByText("Test error message")).toBeInTheDocument();
});
it("shows prompt content when provided", () => {
const props = {
...defaultProps,
selectedPrompt: mockPrompts[0],
promptContent: "Test prompt content",
};
renderWithTabs(<PromptsTab {...props} />);
expect(screen.getByText("Test prompt content")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,135 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import ResourcesTab from "../../components/ResourcesTab";
import { Tabs } from "@/components/ui/tabs";
import type {
Resource,
ResourceTemplate,
} from "@modelcontextprotocol/sdk/types.js";
describe("ResourcesTab", () => {
const mockResources: Resource[] = [
{ uri: "file:///test1.txt", name: "Test 1" },
{ uri: "file:///test2.txt", name: "Test 2" },
];
const mockTemplates: ResourceTemplate[] = [
{
name: "Template 1",
description: "Test template 1",
uriTemplate: "file:///test/{param1}/{param2}.txt",
},
{
name: "Template 2",
description: "Test template 2",
uriTemplate: "file:///other/{name}.txt",
},
];
const defaultProps = {
resources: mockResources,
resourceTemplates: mockTemplates,
listResources: vi.fn(),
clearResources: vi.fn(),
listResourceTemplates: vi.fn(),
clearResourceTemplates: vi.fn(),
readResource: vi.fn(),
selectedResource: null,
setSelectedResource: vi.fn(),
resourceContent: "",
nextCursor: null,
nextTemplateCursor: null,
error: null,
};
const renderWithTabs = (component: React.ReactElement) => {
return render(<Tabs defaultValue="resources">{component}</Tabs>);
};
it("renders resources list", () => {
renderWithTabs(<ResourcesTab {...defaultProps} />);
expect(screen.getByText("Test 1")).toBeInTheDocument();
expect(screen.getByText("Test 2")).toBeInTheDocument();
});
it("renders templates list", () => {
renderWithTabs(<ResourcesTab {...defaultProps} />);
expect(screen.getByText("Template 1")).toBeInTheDocument();
expect(screen.getByText("Template 2")).toBeInTheDocument();
});
it("shows resource content when resource is selected", () => {
const props = {
...defaultProps,
selectedResource: mockResources[0],
resourceContent: "Test content",
};
renderWithTabs(<ResourcesTab {...props} />);
expect(screen.getByText("Test content")).toBeInTheDocument();
});
it("shows template form when template is selected", () => {
renderWithTabs(<ResourcesTab {...defaultProps} />);
fireEvent.click(screen.getByText("Template 1"));
expect(screen.getByText("Test template 1")).toBeInTheDocument();
expect(screen.getByLabelText("param1")).toBeInTheDocument();
expect(screen.getByLabelText("param2")).toBeInTheDocument();
});
it("fills template and reads resource", () => {
const readResource = vi.fn();
const setSelectedResource = vi.fn();
renderWithTabs(
<ResourcesTab
{...defaultProps}
readResource={readResource}
setSelectedResource={setSelectedResource}
/>,
);
// Select template
fireEvent.click(screen.getByText("Template 1"));
// Fill in template parameters
fireEvent.change(screen.getByLabelText("param1"), {
target: { value: "value1" },
});
fireEvent.change(screen.getByLabelText("param2"), {
target: { value: "value2" },
});
// Submit form
fireEvent.click(screen.getByText("Read Resource"));
expect(readResource).toHaveBeenCalledWith("file:///test/value1/value2.txt");
expect(setSelectedResource).toHaveBeenCalledWith(
expect.objectContaining({
uri: "file:///test/value1/value2.txt",
name: "file:///test/value1/value2.txt",
}),
);
});
it("shows error message when error prop is provided", () => {
const props = {
...defaultProps,
error: "Test error message",
};
renderWithTabs(<ResourcesTab {...props} />);
expect(screen.getByText("Test error message")).toBeInTheDocument();
});
it("refreshes resource content when refresh button is clicked", () => {
const readResource = vi.fn();
const props = {
...defaultProps,
selectedResource: mockResources[0],
readResource,
};
renderWithTabs(<ResourcesTab {...props} />);
fireEvent.click(screen.getByText("Refresh"));
expect(readResource).toHaveBeenCalledWith(mockResources[0].uri);
});
});

View File

@@ -0,0 +1,80 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import RootsTab from "../../components/RootsTab";
import { Tabs } from "@/components/ui/tabs";
import type { Root } from "@modelcontextprotocol/sdk/types.js";
describe("RootsTab", () => {
const mockRoots: Root[] = [
{ uri: "file:///test/path1", name: "test1" },
{ uri: "file:///test/path2", name: "test2" },
];
const defaultProps = {
roots: mockRoots,
setRoots: vi.fn(),
onRootsChange: vi.fn(),
};
const renderWithTabs = (component: React.ReactElement) => {
return render(<Tabs defaultValue="roots">{component}</Tabs>);
};
it("renders list of roots", () => {
renderWithTabs(<RootsTab {...defaultProps} />);
expect(screen.getByDisplayValue("file:///test/path1")).toBeInTheDocument();
expect(screen.getByDisplayValue("file:///test/path2")).toBeInTheDocument();
});
it("adds a new root when Add Root button is clicked", () => {
const setRoots = vi.fn();
renderWithTabs(<RootsTab {...defaultProps} setRoots={setRoots} />);
fireEvent.click(screen.getByText("Add Root"));
expect(setRoots).toHaveBeenCalled();
const updateFn = setRoots.mock.calls[0][0];
const result = updateFn(mockRoots);
expect(result).toEqual([...mockRoots, { uri: "file://", name: "" }]);
});
it("removes a root when remove button is clicked", () => {
const setRoots = vi.fn();
renderWithTabs(<RootsTab {...defaultProps} setRoots={setRoots} />);
const removeButtons = screen.getAllByRole("button", {
name: /remove root/i,
});
fireEvent.click(removeButtons[0]);
expect(setRoots).toHaveBeenCalled();
const updateFn = setRoots.mock.calls[0][0];
const result = updateFn(mockRoots);
expect(result).toEqual([mockRoots[1]]);
});
it("updates root URI when input changes", () => {
const setRoots = vi.fn();
renderWithTabs(<RootsTab {...defaultProps} setRoots={setRoots} />);
const firstInput = screen.getByDisplayValue("file:///test/path1");
fireEvent.change(firstInput, { target: { value: "file:///new/path" } });
expect(setRoots).toHaveBeenCalled();
const updateFn = setRoots.mock.calls[0][0];
const result = updateFn(mockRoots);
expect(result[0].uri).toBe("file:///new/path");
expect(result[1]).toEqual(mockRoots[1]);
});
it("calls onRootsChange when Save Changes is clicked", () => {
const onRootsChange = vi.fn();
renderWithTabs(
<RootsTab {...defaultProps} onRootsChange={onRootsChange} />,
);
fireEvent.click(screen.getByText("Save Changes"));
expect(onRootsChange).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,91 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import SamplingTab from "../../components/SamplingTab";
import { Tabs } from "@/components/ui/tabs";
import type { CreateMessageRequest } from "@modelcontextprotocol/sdk/types.js";
describe("SamplingTab", () => {
const mockRequest: CreateMessageRequest = {
model: "test-model",
role: "user",
content: {
type: "text",
text: "Test message",
},
};
const mockPendingRequests = [
{ id: 1, request: mockRequest },
{
id: 2,
request: {
...mockRequest,
content: { type: "text", text: "Another test" },
},
},
];
const defaultProps = {
pendingRequests: mockPendingRequests,
onApprove: vi.fn(),
onReject: vi.fn(),
};
const renderWithTabs = (component: React.ReactElement) => {
return render(<Tabs defaultValue="sampling">{component}</Tabs>);
};
it("renders empty state when no requests", () => {
renderWithTabs(<SamplingTab {...defaultProps} pendingRequests={[]} />);
expect(screen.getByText("No pending requests")).toBeInTheDocument();
});
it("renders list of pending requests", () => {
renderWithTabs(<SamplingTab {...defaultProps} />);
expect(screen.getByText(/Test message/)).toBeInTheDocument();
expect(screen.getByText(/Another test/)).toBeInTheDocument();
});
it("shows request details in JSON format", () => {
renderWithTabs(<SamplingTab {...defaultProps} />);
const requestJson = screen.getAllByText((content) =>
content.includes('"model": "test-model"'),
);
expect(requestJson).toHaveLength(2);
});
it("calls onApprove with stub response when Approve is clicked", () => {
const onApprove = vi.fn();
renderWithTabs(<SamplingTab {...defaultProps} onApprove={onApprove} />);
const approveButtons = screen.getAllByText("Approve");
fireEvent.click(approveButtons[0]);
expect(onApprove).toHaveBeenCalledWith(1, {
model: "stub-model",
stopReason: "endTurn",
role: "assistant",
content: {
type: "text",
text: "This is a stub response.",
},
});
});
it("calls onReject when Reject is clicked", () => {
const onReject = vi.fn();
renderWithTabs(<SamplingTab {...defaultProps} onReject={onReject} />);
const rejectButtons = screen.getAllByText("Reject");
fireEvent.click(rejectButtons[0]);
expect(onReject).toHaveBeenCalledWith(1);
});
it("shows informational alert about sampling requests", () => {
renderWithTabs(<SamplingTab {...defaultProps} />);
expect(
screen.getByText(/When the server requests LLM sampling/),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,34 @@
import "@testing-library/jest-dom";
import { expect, afterEach, vi } from "vitest";
import { cleanup } from "@testing-library/react";
import * as matchers from "@testing-library/jest-dom/matchers";
// @ts-ignore
expect.extend(matchers);
// Mock window.matchMedia
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // Deprecated
removeListener: vi.fn(), // Deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock window.location.hash
Object.defineProperty(window, "location", {
writable: true,
value: { hash: "" },
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
window.location.hash = "";
});

View File

@@ -1,358 +0,0 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import JsonEditor from "./JsonEditor";
export type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue };
export type JsonSchemaType = {
type: "string" | "number" | "integer" | "boolean" | "array" | "object";
description?: string;
properties?: Record<string, JsonSchemaType>;
items?: JsonSchemaType;
};
type JsonObject = { [key: string]: JsonValue };
interface DynamicJsonFormProps {
schema: JsonSchemaType;
value: JsonValue;
onChange: (value: JsonValue) => void;
maxDepth?: number;
}
const 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
};
const DynamicJsonForm = ({
schema,
value,
onChange,
maxDepth = 3,
}: DynamicJsonFormProps) => {
const [isJsonMode, setIsJsonMode] = useState(false);
const [jsonError, setJsonError] = useState<string>();
const generateDefaultValue = (propSchema: JsonSchemaType): JsonValue => {
switch (propSchema.type) {
case "string":
return "";
case "number":
case "integer":
return 0;
case "boolean":
return false;
case "array":
return [];
case "object": {
const obj: JsonObject = {};
if (propSchema.properties) {
Object.entries(propSchema.properties).forEach(([key, prop]) => {
obj[key] = generateDefaultValue(prop);
});
}
return obj;
}
default:
return null;
}
};
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":
case "number":
case "integer":
return (
<Input
type={propSchema.type === "string" ? "text" : "number"}
value={(currentValue as string | number) ?? ""}
onChange={(e) =>
handleFieldChange(
path,
propSchema.type === "string"
? e.target.value
: Number(e.target.value),
)
}
placeholder={propSchema.description}
/>
);
case "boolean":
return (
<Input
type="checkbox"
checked={(currentValue as boolean) ?? false}
onChange={(e) => handleFieldChange(path, e.target.checked)}
className="w-4 h-4"
/>
);
case "object":
if (!propSchema.properties) return null;
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,
(currentValue as JsonObject)?.[key],
[...path, key],
depth + 1,
)}
</div>
))}
</div>
);
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={() => {
handleFieldChange(path, [
...arrayValue,
generateDefaultValue(propSchema.items as JsonSchemaType),
]);
}}
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;
}
const updateArray = (
array: JsonValue[],
path: string[],
value: JsonValue,
): JsonValue[] => {
const [index, ...restPath] = path;
const arrayIndex = Number(index);
// Validate array index
if (isNaN(arrayIndex)) {
console.error(`Invalid array index: ${index}`);
return array;
}
// Check array bounds
if (arrayIndex < 0) {
console.error(`Array index out of bounds: ${arrayIndex} < 0`);
return array;
}
const newArray = [...array];
if (restPath.length === 0) {
newArray[arrayIndex] = value;
} else {
// Ensure index position exists
if (arrayIndex >= array.length) {
console.warn(`Extending array to index ${arrayIndex}`);
newArray.length = arrayIndex + 1;
newArray.fill(null, array.length, arrayIndex);
}
newArray[arrayIndex] = updateValue(
newArray[arrayIndex],
restPath,
value,
);
}
return newArray;
};
const 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)) {
console.warn(`Creating new key in object: ${key}`);
newObj[key] = {};
}
newObj[key] = updateValue(newObj[key], restPath, value);
}
return newObj;
};
const updateValue = (
current: JsonValue,
path: string[],
value: JsonValue,
): JsonValue => {
if (path.length === 0) return value;
try {
if (!current) {
current = !isNaN(Number(path[0])) ? [] : {};
}
// Type checking
if (Array.isArray(current)) {
return updateArray(current, path, value);
} else if (typeof current === "object" && current !== null) {
return updateObject(current, path, value);
} else {
console.error(
`Cannot update path ${path.join(".")} in non-object/array value:`,
current,
);
return current;
}
} catch (error) {
console.error(`Error updating value at path ${path.join(".")}:`, error);
return current;
}
};
try {
const newValue = updateValue(value, path, fieldValue);
onChange(newValue);
} catch (error) {
console.error("Failed to update form value:", error);
// Keep the original value unchanged
onChange(value);
}
};
return (
<div className="space-y-4">
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => setIsJsonMode(!isJsonMode)}
>
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
</Button>
</div>
{isJsonMode ? (
<JsonEditor
value={JSON.stringify(value ?? generateDefaultValue(schema), null, 2)}
onChange={(newValue) => {
try {
onChange(JSON.parse(newValue));
setJsonError(undefined);
} catch (err) {
setJsonError(err instanceof Error ? err.message : "Invalid JSON");
}
}}
error={jsonError}
/>
) : (
renderFormFields(schema, value)
)}
</div>
);
};
export default DynamicJsonForm;

View File

@@ -1,59 +0,0 @@
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 }: JsonEditorProps) => {
const formatJson = (json: string): string => {
try {
return JSON.stringify(JSON.parse(json), null, 2);
} catch {
return json;
}
};
return (
<div className="relative space-y-2">
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => onChange(formatJson(value))}
>
Format JSON
</Button>
</div>
<div
className={`border rounded-md ${
error ? "border-red-500" : "border-gray-200 dark:border-gray-800"
}`}
>
<Editor
value={value}
onValueChange={onChange}
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>
{error && <p className="text-sm text-red-500 mt-1">{error}</p>}
</div>
);
};
export default JsonEditor;

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">
<Button
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>
</div>
</TabsContent>

View File

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

View File

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

View File

@@ -54,6 +54,7 @@ const RootsTab = ({
variant="destructive"
size="sm"
onClick={() => removeRoot(index)}
aria-label="Remove root"
>
<Minus className="h-4 w-4" />
</Button>

View File

@@ -6,8 +6,6 @@ import {
CircleHelp,
Bug,
Github,
Eye,
EyeOff,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -35,8 +33,6 @@ interface SidebarProps {
setSseUrl: (url: string) => void;
env: Record<string, string>;
setEnv: (env: Record<string, string>) => void;
bearerToken: string;
setBearerToken: (token: string) => void;
onConnect: () => void;
stdErrNotifications: StdErrNotification[];
}
@@ -53,15 +49,11 @@ const Sidebar = ({
setSseUrl,
env,
setEnv,
bearerToken,
setBearerToken,
onConnect,
stdErrNotifications,
}: SidebarProps) => {
const [theme, setTheme] = useTheme();
const [showEnvVars, setShowEnvVars] = useState(false);
const [showBearerToken, setShowBearerToken] = useState(false);
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
return (
<div className="w-80 bg-card border-r border-border flex flex-col h-full">
@@ -115,43 +107,15 @@ const Sidebar = ({
</div>
</>
) : (
<>
<div className="space-y-2">
<label className="text-sm font-medium">URL</label>
<Input
placeholder="URL"
value={sseUrl}
onChange={(e) => setSseUrl(e.target.value)}
className="font-mono"
/>
</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>
</>
<div className="space-y-2">
<label className="text-sm font-medium">URL</label>
<Input
placeholder="URL"
value={sseUrl}
onChange={(e) => setSseUrl(e.target.value)}
className="font-mono"
/>
</div>
)}
{transportType === "stdio" && (
<div className="space-y-2">
@@ -170,44 +134,20 @@ const Sidebar = ({
{showEnvVars && (
<div className="space-y-2">
{Object.entries(env).map(([key, value], idx) => (
<div key={idx} className="space-y-2 pb-4">
<div className="flex gap-2">
<div key={idx} className="grid grid-cols-[1fr,auto] gap-2">
<div className="space-y-1">
<Input
placeholder="Key"
value={key}
onChange={(e) => {
const newKey = e.target.value;
const newEnv = { ...env };
delete newEnv[key];
newEnv[newKey] = value;
newEnv[e.target.value] = value;
setEnv(newEnv);
setShownEnvVars((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
next.add(newKey);
}
return next;
});
}}
className="font-mono"
/>
<Button
variant="destructive"
size="icon"
className="h-9 w-9 p-0 shrink-0"
onClick={() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [key]: _removed, ...rest } = env;
setEnv(rest);
}}
>
×
</Button>
</div>
<div className="flex gap-2">
<Input
type={shownEnvVars.has(key) ? "text" : "password"}
placeholder="Value"
value={value}
onChange={(e) => {
@@ -217,45 +157,24 @@ const Sidebar = ({
}}
className="font-mono"
/>
<Button
variant="outline"
size="icon"
className="h-9 w-9 p-0 shrink-0"
onClick={() => {
setShownEnvVars((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}}
aria-label={
shownEnvVars.has(key) ? "Hide value" : "Show value"
}
aria-pressed={shownEnvVars.has(key)}
title={
shownEnvVars.has(key) ? "Hide value" : "Show value"
}
>
{shownEnvVars.has(key) ? (
<Eye className="h-4 w-4" aria-hidden="true" />
) : (
<EyeOff className="h-4 w-4" aria-hidden="true" />
)}
</Button>
</div>
<Button
variant="destructive"
onClick={() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [key]: removed, ...rest } = env;
setEnv(rest);
}}
>
Remove
</Button>
</div>
))}
<Button
variant="outline"
className="w-full mt-2"
onClick={() => {
const key = "";
const newEnv = { ...env };
newEnv[key] = "";
newEnv[""] = "";
setEnv(newEnv);
}}
>

View File

@@ -1,11 +1,9 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import DynamicJsonForm, { JsonSchemaType, JsonValue } from "./DynamicJsonForm";
import {
ListToolsResult,
Tool,
@@ -89,20 +87,11 @@ const ToolsTab = ({
className="max-w-full h-auto"
/>
)}
{item.type === "resource" &&
(item.resource?.mimeType?.startsWith("audio/") ? (
<audio
controls
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">
{JSON.stringify(item.resource, null, 2)}
</pre>
))}
{item.type === "resource" && (
<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">
{JSON.stringify(item.resource, null, 2)}
</pre>
)}
</div>
))}
</>
@@ -161,42 +150,22 @@ const ToolsTab = ({
{selectedTool.description}
</p>
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
([key, value]) => {
const prop = value as JsonSchemaType;
return (
<div key={key}>
<Label
htmlFor={key}
className="block text-sm font-medium text-gray-700"
>
{key}
</Label>
{prop.type === "boolean" ? (
<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" ? (
([key, value]) => (
<div key={key}>
<Label
htmlFor={key}
className="block text-sm font-medium text-gray-700"
>
{key}
</Label>
{
/* @ts-expect-error value type is currently unknown */
value.type === "string" ? (
<Textarea
id={key}
name={key}
placeholder={prop.description}
value={(params[key] as string) ?? ""}
// @ts-expect-error value type is currently unknown
placeholder={value.description}
onChange={(e) =>
setParams({
...params,
@@ -205,45 +174,54 @@ const ToolsTab = ({
}
className="mt-1"
/>
) : prop.type === "object" || prop.type === "array" ? (
<div className="mt-1">
<DynamicJsonForm
schema={{
type: prop.type,
properties: prop.properties,
description: prop.description,
items: prop.items,
}}
value={(params[key] as JsonValue) ?? {}}
onChange={(newValue: JsonValue) => {
setParams({
...params,
[key]: newValue,
});
}}
/>
</div>
) : (
<Input
type={prop.type === "number" ? "number" : "text"}
) : /* @ts-expect-error value type is currently unknown */
value.type === "object" ? (
<Textarea
id={key}
name={key}
placeholder={prop.description}
// @ts-expect-error value type is currently unknown
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) =>
setParams({
...params,
[key]:
prop.type === "number"
// @ts-expect-error value type is currently unknown
value.type === "number"
? Number(e.target.value)
: e.target.value,
})
}
className="mt-1"
/>
)}
</div>
);
},
)
}
</div>
),
)}
<Button onClick={() => callTool(selectedTool.name, params)}>
<Send className="w-4 h-4 mr-2" />

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;
}
button[role="checkbox"] {
padding: 0;
}
@media (prefers-color-scheme: light) {
:root {
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,35 +1,21 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import {
SSEClientTransport,
SseError,
} from "@modelcontextprotocol/sdk/client/sse.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import {
ClientNotification,
ClientRequest,
CreateMessageRequestSchema,
ListRootsRequestSchema,
ProgressNotificationSchema,
ResourceUpdatedNotificationSchema,
Request,
Result,
ServerCapabilities,
PromptReference,
ResourceReference,
McpError,
CompleteResultSchema,
ErrorCode,
} from "@modelcontextprotocol/sdk/types.js";
import { useState } from "react";
import { toast } from "react-toastify";
import { z } from "zod";
import { SESSION_KEYS } from "../constants";
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { authProvider } from "../auth";
import { z } from "zod";
const params = new URLSearchParams(window.location.search);
const DEFAULT_REQUEST_TIMEOUT_MSEC =
parseInt(params.get("timeout") ?? "") || 10000;
const DEFAULT_REQUEST_TIMEOUT_MSEC = 10000;
interface UseConnectionOptions {
transportType: "stdio" | "sse";
@@ -38,7 +24,6 @@ interface UseConnectionOptions {
sseUrl: string;
env: Record<string, string>;
proxyServerUrl: string;
bearerToken?: string;
requestTimeout?: number;
onNotification?: (notification: Notification) => void;
onStdErrNotification?: (notification: Notification) => void;
@@ -46,12 +31,6 @@ interface UseConnectionOptions {
getRoots?: () => any[];
}
interface RequestOptions {
signal?: AbortSignal;
timeout?: number;
suppressToast?: boolean;
}
export function useConnection({
transportType,
command,
@@ -59,7 +38,6 @@ export function useConnection({
sseUrl,
env,
proxyServerUrl,
bearerToken,
requestTimeout = DEFAULT_REQUEST_TIMEOUT_MSEC,
onNotification,
onStdErrNotification,
@@ -75,7 +53,6 @@ export function useConnection({
const [requestHistory, setRequestHistory] = useState<
{ request: string; response?: string }[]
>([]);
const [completionsSupported, setCompletionsSupported] = useState(true);
const pushHistory = (request: object, response?: object) => {
setRequestHistory((prev) => [
@@ -90,8 +67,7 @@ export function useConnection({
const makeRequest = async <T extends z.ZodType>(
request: ClientRequest,
schema: T,
options?: RequestOptions,
): Promise<z.output<T>> => {
) => {
if (!mcpClient) {
throw new Error("MCP client not connected");
}
@@ -100,12 +76,12 @@ export function useConnection({
const abortController = new AbortController();
const timeoutId = setTimeout(() => {
abortController.abort("Request timed out");
}, options?.timeout ?? requestTimeout);
}, requestTimeout);
let response;
try {
response = await mcpClient.request(request, schema, {
signal: options?.signal ?? abortController.signal,
signal: abortController.signal,
});
pushHistory(request, response);
} catch (error) {
@@ -119,88 +95,28 @@ export function useConnection({
return response;
} catch (e: unknown) {
if (!options?.suppressToast) {
const errorString = (e as Error).message ?? String(e);
toast.error(errorString);
}
throw e;
}
};
const errorString = (e as Error).message ?? String(e);
toast.error(errorString);
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;
}
};
const sendNotification = async (notification: ClientNotification) => {
if (!mcpClient) {
const error = new Error("MCP client not connected");
toast.error(error.message);
throw error;
throw new Error("MCP client not connected");
}
try {
await mcpClient.notification(notification);
// Log successful notifications
pushHistory(notification);
} catch (e: unknown) {
if (e instanceof McpError) {
// Log MCP protocol errors
pushHistory(notification, { error: e.message });
}
toast.error(e instanceof Error ? e.message : String(e));
toast.error((e as Error).message ?? String(e));
throw e;
}
};
const handleAuthError = async (error: unknown) => {
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) => {
const connect = async () => {
try {
const client = new Client<Request, Notification, Result>(
{
@@ -228,35 +144,13 @@ export function useConnection({
backendUrl.searchParams.append("url", sseUrl);
}
// Inject auth manually instead of using SSEClientTransport, because we're
// 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,
},
});
const clientTransport = new SSEClientTransport(backendUrl);
if (onNotification) {
client.setNotificationHandler(
ProgressNotificationSchema,
onNotification,
);
client.setNotificationHandler(
ResourceUpdatedNotificationSchema,
onNotification,
);
}
if (onStdErrNotification) {
@@ -266,25 +160,10 @@ export function useConnection({
);
}
try {
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;
}
await client.connect(clientTransport);
const capabilities = client.getServerCapabilities();
setServerCapabilities(capabilities ?? null);
setCompletionsSupported(true); // Reset completions support on new connection
if (onPendingRequest) {
client.setRequestHandler(CreateMessageRequestSchema, (request) => {
@@ -315,8 +194,6 @@ export function useConnection({
requestHistory,
makeRequest,
sendNotification,
handleCompletion,
completionsSupported,
connect,
};
}

View File

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

View File

@@ -1,11 +1,10 @@
import react from "@vitejs/plugin-react";
import path from "path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
@@ -15,8 +14,8 @@ export default defineConfig({
minify: false,
rollupOptions: {
output: {
manualChunks: undefined,
},
},
},
manualChunks: undefined
}
}
}
});

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

@@ -0,0 +1,19 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/__tests__/setup/setup.ts'],
include: ['src/__tests__/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
},
})

2603
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector",
"version": "0.6.0",
"version": "0.3.0",
"description": "Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@@ -22,7 +22,6 @@
],
"scripts": {
"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-client": "cd client && npm run build",
"build": "npm run build-server && npm run build-client",
@@ -34,11 +33,11 @@
"publish-all": "npm publish --workspaces --access public && npm publish --access public"
},
"dependencies": {
"@modelcontextprotocol/inspector-client": "^0.6.0",
"@modelcontextprotocol/inspector-server": "^0.6.0",
"@modelcontextprotocol/inspector-client": "0.3.0",
"@modelcontextprotocol/inspector-server": "0.3.0",
"concurrently": "^9.0.1",
"shell-quote": "^1.8.2",
"spawn-rx": "^5.1.2",
"spawn-rx": "^5.1.0",
"ts-node": "^10.9.2"
},
"devDependencies": {

View File

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

View File

@@ -1,28 +1,23 @@
#!/usr/bin/env node
import cors from "cors";
import EventSource from "eventsource";
import { parseArgs } from "node:util";
import { parse as shellParseArgs } from "shell-quote";
import {
SSEClientTransport,
SseError,
} from "@modelcontextprotocol/sdk/client/sse.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import {
StdioClientTransport,
getDefaultEnvironment,
} from "@modelcontextprotocol/sdk/client/stdio.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import express from "express";
import { findActualExecutable } from "spawn-rx";
import mcpProxy from "./mcpProxy.js";
import { findActualExecutable } from "spawn-rx";
const SSE_HEADERS_PASSTHROUGH = ["authorization"];
const defaultEnvironment = {
...getDefaultEnvironment(),
...(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({
args: process.argv.slice(2),
@@ -37,8 +32,7 @@ app.use(cors());
let webAppTransports: SSEServerTransport[] = [];
const createTransport = async (req: express.Request) => {
const query = req.query;
const createTransport = async (query: express.Request["query"]) => {
console.log("Query parameters:", query);
const transportType = query.transportType as string;
@@ -46,12 +40,13 @@ const createTransport = async (req: express.Request) => {
if (transportType === "stdio") {
const command = query.command as string;
const origArgs = shellParseArgs(query.args as string) as string[];
const queryEnv = query.env ? JSON.parse(query.env as string) : {};
const env = { ...process.env, ...defaultEnvironment, ...queryEnv };
const env = query.env ? JSON.parse(query.env as string) : undefined;
const { cmd, args } = findActualExecutable(command, origArgs);
console.log(`Stdio transport: command=${cmd}, args=${args}`);
console.log(
`Stdio transport: command=${cmd}, args=${args}, env=${JSON.stringify(env)}`,
);
const transport = new StdioClientTransport({
command: cmd,
@@ -66,26 +61,9 @@ const createTransport = async (req: express.Request) => {
return transport;
} else if (transportType === "sse") {
const url = query.url as string;
const headers: HeadersInit = {};
for (const key of SSE_HEADERS_PASSTHROUGH) {
if (req.headers[key] === undefined) {
continue;
}
console.log(`SSE transport: url=${url}`);
const value = req.headers[key];
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,
},
});
const transport = new SSEClientTransport(new URL(url));
await transport.start();
console.log("Connected to SSE transport");
@@ -100,21 +78,7 @@ app.get("/sse", async (req, res) => {
try {
console.log("New SSE connection");
let backingServerTransport;
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;
}
const backingServerTransport = await createTransport(req.query);
console.log("Connected MCP client to backing server transport");
@@ -141,6 +105,9 @@ app.get("/sse", async (req, res) => {
mcpProxy({
transportToClient: webAppTransport,
transportToServer: backingServerTransport,
onerror: (error) => {
console.error(error);
},
});
console.log("Set up MCP proxy");
@@ -169,6 +136,8 @@ app.post("/message", async (req, res) => {
app.get("/config", (req, res) => {
try {
const defaultEnvironment = getDefaultEnvironment();
res.json({
defaultEnvironment,
defaultCommand: values.env,
@@ -181,16 +150,4 @@ app.get("/config", (req, res) => {
});
const PORT = process.env.PORT || 3000;
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);
}
app.listen(PORT, () => {});

View File

@@ -1,29 +1,23 @@
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({
transportToClient,
transportToServer,
onerror,
}: {
transportToClient: Transport;
transportToServer: Transport;
onerror: (error: Error) => void;
}) {
let transportToClientClosed = false;
let transportToServerClosed = false;
transportToClient.onmessage = (message) => {
transportToServer.send(message).catch(onServerError);
transportToServer.send(message).catch(onerror);
};
transportToServer.onmessage = (message) => {
transportToClient.send(message).catch(onClientError);
transportToClient.send(message).catch(onerror);
};
transportToClient.onclose = () => {
@@ -32,7 +26,7 @@ export default function mcpProxy({
}
transportToClientClosed = true;
transportToServer.close().catch(onServerError);
transportToServer.close().catch(onerror);
};
transportToServer.onclose = () => {
@@ -40,9 +34,10 @@ export default function mcpProxy({
return;
}
transportToServerClosed = true;
transportToClient.close().catch(onClientError);
transportToClient.close().catch(onerror);
};
transportToClient.onerror = onClientError;
transportToServer.onerror = onServerError;
transportToClient.onerror = onerror;
transportToServer.onerror = onerror;
}