Compare commits
42 Commits
ashwin/tes
...
0.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d438760e36 | ||
|
|
d0ad677784 | ||
|
|
1d4e8885db | ||
|
|
a87bd17f51 | ||
|
|
afe14bc883 | ||
|
|
04faff4757 | ||
|
|
0882a3e0e5 | ||
|
|
fce6644e30 | ||
|
|
51ea4bc6ac | ||
|
|
0648ba44e3 | ||
|
|
c22f91858c | ||
|
|
99d7592ac9 | ||
|
|
3bc776f7cd | ||
|
|
a6d22cf1e4 | ||
|
|
731ee588c2 | ||
|
|
af8877064e | ||
|
|
874320ebe6 | ||
|
|
e470eb5c51 | ||
|
|
02cfb47c83 | ||
|
|
23f89e49b8 | ||
|
|
16cb59670c | ||
|
|
1c4ad60354 | ||
|
|
8a20f7711a | ||
|
|
8bb5308797 | ||
|
|
14db05c2a2 | ||
|
|
e7697eb5cd | ||
|
|
c1e06c4af0 | ||
|
|
60b8892dd3 | ||
|
|
2b53a8399c | ||
|
|
361f9d109b | ||
|
|
7ec661e8bd | ||
|
|
98e6f0e5ec | ||
|
|
ec150eb8b4 | ||
|
|
052de8690d | ||
|
|
a976aefb39 | ||
|
|
5a5873277c | ||
|
|
715936d747 | ||
|
|
d973f58bef | ||
|
|
35effc4d16 | ||
|
|
14802b8043 | ||
|
|
068d21387a | ||
|
|
66b1b73448 |
3
.github/workflows/main.yml
vendored
3
.github/workflows/main.yml
vendored
@@ -14,6 +14,9 @@ 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
1
.gitignore
vendored
@@ -2,6 +2,5 @@
|
||||
node_modules
|
||||
server/build
|
||||
client/dist
|
||||
client/coverage
|
||||
client/tsconfig.app.tsbuildinfo
|
||||
client/tsconfig.node.tsbuildinfo
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
packages
|
||||
server/build
|
||||
CODE_OF_CONDUCT.md
|
||||
SECURITY.md
|
||||
|
||||
16
README.md
16
README.md
@@ -14,10 +14,20 @@ To inspect an MCP server implementation, there's no need to clone this repo. Ins
|
||||
npx @modelcontextprotocol/inspector build/index.js
|
||||
```
|
||||
|
||||
You can also pass arguments along which will get passed as arguments to your MCP server:
|
||||
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:
|
||||
|
||||
```
|
||||
npx @modelcontextprotocol/inspector build/index.js arg1 arg2 ...
|
||||
```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 build/index.js
|
||||
|
||||
# Pass both environment variables and arguments
|
||||
npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 build/index.js arg1 arg2
|
||||
|
||||
# Use -- to separate inspector flags from server arguments
|
||||
npx @modelcontextprotocol/inspector -e KEY=$VALUE -- build/index.js -e server-flag
|
||||
```
|
||||
|
||||
The inspector runs both a client UI (default port 5173) and an MCP proxy server (default port 3000). Open the client UI in your browser to use the inspector. You can customize the ports if needed:
|
||||
|
||||
34
bin/cli.js
34
bin/cli.js
@@ -11,8 +11,32 @@ function delay(ms) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Get command line arguments
|
||||
const [, , command, ...mcpServerArgs] = process.argv;
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
const inspectorServerPath = resolve(
|
||||
__dirname,
|
||||
@@ -52,7 +76,11 @@ async function main() {
|
||||
...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []),
|
||||
],
|
||||
{
|
||||
env: { ...process.env, PORT: SERVER_PORT },
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: SERVER_PORT,
|
||||
MCP_ENV_VARS: JSON.stringify(envVars),
|
||||
},
|
||||
signal: abort.signal,
|
||||
echoOutput: true,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@modelcontextprotocol/inspector-client",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"description": "Client-side application for the Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
@@ -18,12 +18,10 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.3",
|
||||
"@modelcontextprotocol/sdk": "^1.4.1",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
@@ -32,6 +30,7 @@
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.447.0",
|
||||
"pkce-challenge": "^4.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-toastify": "^10.0.6",
|
||||
@@ -42,25 +41,20 @@
|
||||
},
|
||||
"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",
|
||||
"vitest": "^2.1.8"
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useDraggablePane } from "./lib/hooks/useDraggablePane";
|
||||
import { useConnection } from "./lib/hooks/useConnection";
|
||||
import {
|
||||
ClientRequest,
|
||||
CompatibilityCallToolResult,
|
||||
@@ -10,15 +8,17 @@ import {
|
||||
ListPromptsResultSchema,
|
||||
ListResourcesResultSchema,
|
||||
ListResourceTemplatesResultSchema,
|
||||
ReadResourceResultSchema,
|
||||
ListToolsResultSchema,
|
||||
ReadResourceResultSchema,
|
||||
Resource,
|
||||
ResourceTemplate,
|
||||
Root,
|
||||
ServerNotification,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import React, { Suspense, useEffect, useRef, useState } from "react";
|
||||
import { useConnection } from "./lib/hooks/useConnection";
|
||||
import { useDraggablePane } from "./lib/hooks/useDraggablePane";
|
||||
|
||||
import { StdErrNotification } from "./lib/notificationTypes";
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
|
||||
import { toast } from "react-toastify";
|
||||
import { z } from "zod";
|
||||
import "./App.css";
|
||||
import ConsoleTab from "./components/ConsoleTab";
|
||||
@@ -49,6 +50,17 @@ 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[]
|
||||
@@ -71,8 +83,14 @@ const App = () => {
|
||||
return localStorage.getItem("lastArgs") || "";
|
||||
});
|
||||
|
||||
const [sseUrl, setSseUrl] = useState<string>("http://localhost:3001/sse");
|
||||
const [transportType, setTransportType] = useState<"stdio" | "sse">("stdio");
|
||||
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 [notifications, setNotifications] = useState<ServerNotification[]>([]);
|
||||
const [stdErrNotifications, setStdErrNotifications] = useState<
|
||||
StdErrNotification[]
|
||||
@@ -190,6 +208,31 @@ const App = () => {
|
||||
localStorage.setItem("lastArgs", args);
|
||||
}, [args]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("lastSseUrl", sseUrl);
|
||||
}, [sseUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("lastTransportType", transportType);
|
||||
}, [transportType]);
|
||||
|
||||
// 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())
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
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 = "";
|
||||
});
|
||||
48
client/src/components/OAuthCallback.tsx
Normal file
48
client/src/components/OAuthCallback.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { handleOAuthCallback } from "../lib/auth";
|
||||
import { SESSION_KEYS } from "../lib/constants";
|
||||
|
||||
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 accessToken = await handleOAuthCallback(serverUrl, code);
|
||||
// Store the access token for future use
|
||||
sessionStorage.setItem(SESSION_KEYS.ACCESS_TOKEN, accessToken);
|
||||
// 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;
|
||||
@@ -54,7 +54,6 @@ const RootsTab = ({
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => removeRoot(index)}
|
||||
aria-label="Remove root"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
CircleHelp,
|
||||
Bug,
|
||||
Github,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -54,6 +56,7 @@ const Sidebar = ({
|
||||
}: SidebarProps) => {
|
||||
const [theme, setTheme] = useTheme();
|
||||
const [showEnvVars, setShowEnvVars] = 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">
|
||||
@@ -134,20 +137,44 @@ const Sidebar = ({
|
||||
{showEnvVars && (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(env).map(([key, value], idx) => (
|
||||
<div key={idx} className="grid grid-cols-[1fr,auto] gap-2">
|
||||
<div className="space-y-1">
|
||||
<div key={idx} className="space-y-2 pb-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Key"
|
||||
value={key}
|
||||
onChange={(e) => {
|
||||
const newKey = e.target.value;
|
||||
const newEnv = { ...env };
|
||||
delete newEnv[key];
|
||||
newEnv[e.target.value] = value;
|
||||
newEnv[newKey] = 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) => {
|
||||
@@ -157,24 +184,45 @@ 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[""] = "";
|
||||
newEnv[key] = "";
|
||||
setEnv(newEnv);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -87,11 +87,20 @@ const ToolsTab = ({
|
||||
className="max-w-full h-auto"
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
|
||||
93
client/src/lib/auth.ts
Normal file
93
client/src/lib/auth.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import pkceChallenge from "pkce-challenge";
|
||||
import { SESSION_KEYS } from "./constants";
|
||||
|
||||
export interface OAuthMetadata {
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
}
|
||||
|
||||
export async function discoverOAuthMetadata(
|
||||
serverUrl: string,
|
||||
): Promise<OAuthMetadata> {
|
||||
try {
|
||||
const url = new URL("/.well-known/oauth-authorization-server", serverUrl);
|
||||
const response = await fetch(url.toString());
|
||||
|
||||
if (response.ok) {
|
||||
const metadata = await response.json();
|
||||
return {
|
||||
authorization_endpoint: metadata.authorization_endpoint,
|
||||
token_endpoint: metadata.token_endpoint,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("OAuth metadata discovery failed:", error);
|
||||
}
|
||||
|
||||
// Fall back to default endpoints
|
||||
const baseUrl = new URL(serverUrl);
|
||||
return {
|
||||
authorization_endpoint: new URL("/authorize", baseUrl).toString(),
|
||||
token_endpoint: new URL("/token", baseUrl).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function startOAuthFlow(serverUrl: string): Promise<string> {
|
||||
// Generate PKCE challenge
|
||||
const challenge = await pkceChallenge();
|
||||
const codeVerifier = challenge.code_verifier;
|
||||
const codeChallenge = challenge.code_challenge;
|
||||
|
||||
// Store code verifier for later use
|
||||
sessionStorage.setItem(SESSION_KEYS.CODE_VERIFIER, codeVerifier);
|
||||
|
||||
// Discover OAuth endpoints
|
||||
const metadata = await discoverOAuthMetadata(serverUrl);
|
||||
|
||||
// Build authorization URL
|
||||
const authUrl = new URL(metadata.authorization_endpoint);
|
||||
authUrl.searchParams.set("response_type", "code");
|
||||
authUrl.searchParams.set("code_challenge", codeChallenge);
|
||||
authUrl.searchParams.set("code_challenge_method", "S256");
|
||||
authUrl.searchParams.set(
|
||||
"redirect_uri",
|
||||
window.location.origin + "/oauth/callback",
|
||||
);
|
||||
|
||||
return authUrl.toString();
|
||||
}
|
||||
|
||||
export async function handleOAuthCallback(
|
||||
serverUrl: string,
|
||||
code: string,
|
||||
): Promise<string> {
|
||||
// Get stored code verifier
|
||||
const codeVerifier = sessionStorage.getItem(SESSION_KEYS.CODE_VERIFIER);
|
||||
if (!codeVerifier) {
|
||||
throw new Error("No code verifier found");
|
||||
}
|
||||
|
||||
// Discover OAuth endpoints
|
||||
const metadata = await discoverOAuthMetadata(serverUrl);
|
||||
|
||||
// Exchange code for tokens
|
||||
const response = await fetch(metadata.token_endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
code_verifier: codeVerifier,
|
||||
redirect_uri: window.location.origin + "/oauth/callback",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Token exchange failed");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.access_token;
|
||||
}
|
||||
6
client/src/lib/constants.ts
Normal file
6
client/src/lib/constants.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// OAuth-related session storage keys
|
||||
export const SESSION_KEYS = {
|
||||
CODE_VERIFIER: "mcp_code_verifier",
|
||||
SERVER_URL: "mcp_server_url",
|
||||
ACCESS_TOKEN: "mcp_access_token",
|
||||
} as const;
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
import {
|
||||
SSEClientTransport,
|
||||
SseError,
|
||||
} from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
import {
|
||||
ClientNotification,
|
||||
ClientRequest,
|
||||
@@ -12,8 +15,10 @@ import {
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
|
||||
import { z } from "zod";
|
||||
import { startOAuthFlow } from "../auth";
|
||||
import { SESSION_KEYS } from "../constants";
|
||||
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT_MSEC = 10000;
|
||||
|
||||
@@ -144,7 +149,20 @@ export function useConnection({
|
||||
backendUrl.searchParams.append("url", sseUrl);
|
||||
}
|
||||
|
||||
const clientTransport = new SSEClientTransport(backendUrl);
|
||||
const headers: HeadersInit = {};
|
||||
const accessToken = sessionStorage.getItem(SESSION_KEYS.ACCESS_TOKEN);
|
||||
if (accessToken) {
|
||||
headers["Authorization"] = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
const clientTransport = new SSEClientTransport(backendUrl, {
|
||||
eventSourceInit: {
|
||||
fetch: (url, init) => fetch(url, { ...init, headers }),
|
||||
},
|
||||
requestInit: {
|
||||
headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (onNotification) {
|
||||
client.setNotificationHandler(
|
||||
@@ -160,7 +178,20 @@ export function useConnection({
|
||||
);
|
||||
}
|
||||
|
||||
await client.connect(clientTransport);
|
||||
try {
|
||||
await client.connect(clientTransport);
|
||||
} catch (error) {
|
||||
console.error("Failed to connect to MCP server:", error);
|
||||
if (error instanceof SseError && error.code === 401) {
|
||||
// Store the server URL for the callback handler
|
||||
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, sseUrl);
|
||||
const redirectUrl = await startOAuthFlow(sseUrl);
|
||||
window.location.href = redirectUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const capabilities = client.getServerCapabilities();
|
||||
setServerCapabilities(capabilities ?? null);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
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"),
|
||||
@@ -14,8 +15,8 @@ export default defineConfig({
|
||||
minify: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
manualChunks: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
/// <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}'],
|
||||
},
|
||||
})
|
||||
1347
package-lock.json
generated
1347
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@modelcontextprotocol/inspector",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"description": "Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@modelcontextprotocol/inspector-server",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"description": "Server-side application for the Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
@@ -20,16 +20,14 @@
|
||||
},
|
||||
"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.0.3",
|
||||
"@modelcontextprotocol/sdk": "^1.4.1",
|
||||
"cors": "^2.8.5",
|
||||
"eventsource": "^2.0.2",
|
||||
"express": "^4.21.0",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.23.8"
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
#!/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 } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
import {
|
||||
SSEClientTransport,
|
||||
SseError,
|
||||
} 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 mcpProxy from "./mcpProxy.js";
|
||||
import { findActualExecutable } from "spawn-rx";
|
||||
import mcpProxy from "./mcpProxy.js";
|
||||
|
||||
// Polyfill EventSource for an SSE client in Node.js
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(global as any).EventSource = EventSource;
|
||||
const SSE_HEADERS_PASSTHROUGH = ["authorization"];
|
||||
|
||||
const defaultEnvironment = {
|
||||
...getDefaultEnvironment(),
|
||||
...(process.env.MCP_ENV_VARS ? JSON.parse(process.env.MCP_ENV_VARS) : {}),
|
||||
};
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: process.argv.slice(2),
|
||||
@@ -32,7 +37,8 @@ app.use(cors());
|
||||
|
||||
let webAppTransports: SSEServerTransport[] = [];
|
||||
|
||||
const createTransport = async (query: express.Request["query"]) => {
|
||||
const createTransport = async (req: express.Request) => {
|
||||
const query = req.query;
|
||||
console.log("Query parameters:", query);
|
||||
|
||||
const transportType = query.transportType as string;
|
||||
@@ -40,13 +46,12 @@ const createTransport = async (query: express.Request["query"]) => {
|
||||
if (transportType === "stdio") {
|
||||
const command = query.command as string;
|
||||
const origArgs = shellParseArgs(query.args as string) as string[];
|
||||
const env = query.env ? JSON.parse(query.env as string) : undefined;
|
||||
const queryEnv = query.env ? JSON.parse(query.env as string) : {};
|
||||
const env = { ...process.env, ...defaultEnvironment, ...queryEnv };
|
||||
|
||||
const { cmd, args } = findActualExecutable(command, origArgs);
|
||||
|
||||
console.log(
|
||||
`Stdio transport: command=${cmd}, args=${args}, env=${JSON.stringify(env)}`,
|
||||
);
|
||||
console.log(`Stdio transport: command=${cmd}, args=${args}`);
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: cmd,
|
||||
@@ -61,9 +66,26 @@ const createTransport = async (query: express.Request["query"]) => {
|
||||
return transport;
|
||||
} else if (transportType === "sse") {
|
||||
const url = query.url as string;
|
||||
console.log(`SSE transport: url=${url}`);
|
||||
const headers: HeadersInit = {};
|
||||
for (const key of SSE_HEADERS_PASSTHROUGH) {
|
||||
if (req.headers[key] === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const transport = new SSEClientTransport(new 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,
|
||||
},
|
||||
});
|
||||
await transport.start();
|
||||
|
||||
console.log("Connected to SSE transport");
|
||||
@@ -78,7 +100,21 @@ app.get("/sse", async (req, res) => {
|
||||
try {
|
||||
console.log("New SSE connection");
|
||||
|
||||
const backingServerTransport = await createTransport(req.query);
|
||||
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;
|
||||
}
|
||||
|
||||
console.log("Connected MCP client to backing server transport");
|
||||
|
||||
@@ -105,9 +141,6 @@ app.get("/sse", async (req, res) => {
|
||||
mcpProxy({
|
||||
transportToClient: webAppTransport,
|
||||
transportToServer: backingServerTransport,
|
||||
onerror: (error) => {
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
|
||||
console.log("Set up MCP proxy");
|
||||
@@ -136,8 +169,6 @@ app.post("/message", async (req, res) => {
|
||||
|
||||
app.get("/config", (req, res) => {
|
||||
try {
|
||||
const defaultEnvironment = getDefaultEnvironment();
|
||||
|
||||
res.json({
|
||||
defaultEnvironment,
|
||||
defaultCommand: values.env,
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
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(onerror);
|
||||
transportToServer.send(message).catch(onServerError);
|
||||
};
|
||||
|
||||
transportToServer.onmessage = (message) => {
|
||||
transportToClient.send(message).catch(onerror);
|
||||
transportToClient.send(message).catch(onClientError);
|
||||
};
|
||||
|
||||
transportToClient.onclose = () => {
|
||||
@@ -26,7 +32,7 @@ export default function mcpProxy({
|
||||
}
|
||||
|
||||
transportToClientClosed = true;
|
||||
transportToServer.close().catch(onerror);
|
||||
transportToServer.close().catch(onServerError);
|
||||
};
|
||||
|
||||
transportToServer.onclose = () => {
|
||||
@@ -34,10 +40,9 @@ export default function mcpProxy({
|
||||
return;
|
||||
}
|
||||
transportToServerClosed = true;
|
||||
|
||||
transportToClient.close().catch(onerror);
|
||||
transportToClient.close().catch(onClientError);
|
||||
};
|
||||
|
||||
transportToClient.onerror = onerror;
|
||||
transportToServer.onerror = onerror;
|
||||
transportToClient.onerror = onClientError;
|
||||
transportToServer.onerror = onServerError;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user