Compare commits

..

42 Commits

Author SHA1 Message Date
Jerome
d438760e36 Merge pull request #99 from evalstate/feature/audio-rendering
Render Audio Player if Tool Result resource mime type is audio.
2025-02-03 12:28:41 +13:00
Jerome
d0ad677784 Update ToolsTab.tsx
Linting on originally approved commit
2025-02-03 12:24:52 +13:00
Justin Spahr-Summers
1d4e8885db Merge pull request #131 from modelcontextprotocol/justin/sse-auth
OAuth support for SSE
2025-01-29 11:20:48 +00:00
=
a87bd17f51 ❯ npx prettier --check .
Checking formatting...
[warn] client/src/components/ToolsTab.tsx
[warn] Code style issues found in the above file. Run Prettier with --write to fix.

inspector on  feature/audio-rendering [$⇡] is 📦 v0.3.0 via  v22.11.0
❯ npx prettier --write client/src/components/ToolsTab.tsx
client/src/components/ToolsTab.tsx 109ms
2025-01-28 08:28:25 +00:00
=
afe14bc883 Merge branch 'feature/audio-rendering' of https://github.com/evalstate/inspector into feature/audio-rendering 2025-01-28 08:18:24 +00:00
Jerome
04faff4757 Merge branch 'main' into feature/audio-rendering 2025-01-28 02:15:55 -05:00
Justin Spahr-Summers
0882a3e0e5 Formatting 2025-01-24 15:23:24 +00:00
Justin Spahr-Summers
fce6644e30 Fix double fetching 2025-01-24 15:22:40 +00:00
Justin Spahr-Summers
51ea4bc6ac Add toast when OAuth succeeds 2025-01-24 15:19:41 +00:00
Justin Spahr-Summers
0648ba44e3 Auto-reconnect after OAuth 2025-01-24 15:17:03 +00:00
Justin Spahr-Summers
c22f91858c Remember last selected transport and SSE URL 2025-01-24 15:04:22 +00:00
Justin Spahr-Summers
99d7592ac9 Fix error state being briefly shown before OAuth 2025-01-24 15:02:34 +00:00
Justin Spahr-Summers
3bc776f7cd Fix Vite config 2025-01-24 14:55:10 +00:00
Justin Spahr-Summers
a6d22cf1e4 Bump SDK version 2025-01-24 14:54:46 +00:00
Justin Spahr-Summers
731ee588c2 Fix Authorization header passthrough
Node.js headers are lowercase
2025-01-24 13:55:43 +00:00
Justin Spahr-Summers
af8877064e Set Authorization header from client 2025-01-24 13:55:32 +00:00
Justin Spahr-Summers
874320ebe6 Token exchange body needs to be JSON 2025-01-24 13:44:26 +00:00
Justin Spahr-Summers
e470eb5c51 Fix React import 2025-01-24 13:27:20 +00:00
Justin Spahr-Summers
02cfb47c83 Extract session storage keys into constants 2025-01-24 13:09:58 +00:00
Justin Spahr-Summers
23f89e49b8 Implement OAuth callback 2025-01-24 13:08:39 +00:00
Justin Spahr-Summers
16cb59670c OAuth callback handler (not yet attached) 2025-01-24 11:37:35 +00:00
Justin Spahr-Summers
1c4ad60354 Redirect into OAuth flow upon receiving 401 2025-01-24 11:34:07 +00:00
Justin Spahr-Summers
8a20f7711a Use new SseError class from SDK 2025-01-24 11:27:40 +00:00
Justin Spahr-Summers
8bb5308797 Report SSE 401 errors to the client 2025-01-24 11:04:44 +00:00
Justin Spahr-Summers
14db05c2a2 Clarify inspector-server error logging 2025-01-23 17:19:39 +00:00
Justin Spahr-Summers
e7697eb5cd Pass through Authorization headers sent to inspector server 2025-01-23 16:45:37 +00:00
Justin Spahr-Summers
c1e06c4af0 Server doesn't need to inject eventsource anymore 2025-01-23 16:45:12 +00:00
Justin Spahr-Summers
60b8892dd3 Pre-emptively bump npm package versions
Before I forget!
2025-01-23 16:30:19 +00:00
Justin Spahr-Summers
2b53a8399c Bump SDK 2025-01-23 16:29:43 +00:00
Justin Spahr-Summers
361f9d109b Merge pull request #128 from modelcontextprotocol/dependabot/npm_and_yarn/npm_and_yarn-f25c717a0f
Bump vite from 5.4.11 to 5.4.12 in the npm_and_yarn group across 1 directory
2025-01-22 12:02:57 +00:00
dependabot[bot]
7ec661e8bd Bump vite in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 5.4.11 to 5.4.12
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.12/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.12/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-22 04:50:31 +00:00
Ashwin Bhat
98e6f0e5ec Merge pull request #124 from modelcontextprotocol/ashwin/envvar
allow passing env vars to server from command line
2025-01-13 12:11:25 -08:00
Ashwin Bhat
ec150eb8b4 prettier 2025-01-10 07:53:55 -08:00
Ashwin Bhat
052de8690d respond to PR feedback 2025-01-10 07:51:55 -08:00
Ashwin Bhat
a976aefb39 allow passing env vars to server from command line 2025-01-10 07:51:54 -08:00
Ashwin Bhat
5a5873277c Merge pull request #123 from modelcontextprotocol/ashwin/prettier
enforce prettier formatting
2025-01-10 07:28:29 -08:00
Ashwin Bhat
715936d747 run prettier 2025-01-09 11:01:35 -08:00
Ashwin Bhat
d973f58bef run prettier check in CI 2025-01-09 11:01:28 -08:00
evalstate
35effc4d16 Merge branch 'main' into feature/audio-rendering 2024-12-05 19:50:21 +00:00
evalstate
14802b8043 Merge branch 'main' into feature/audio-rendering 2024-12-03 17:11:47 +00:00
=
068d21387a extended type for "audio" update to spec 2024-12-01 12:47:50 +00:00
=
66b1b73448 Render HTML5 Audio Player if Tool Result resource mimetype is audio. 2024-12-01 10:24:12 +00:00
30 changed files with 493 additions and 2247 deletions

View File

@@ -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
View File

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

View File

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

View File

@@ -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:

View File

@@ -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,
},

View File

@@ -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"
}
}

View File

@@ -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())

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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");
});
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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 = "";
});

View 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;

View File

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

View File

@@ -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);
}}
>

View File

@@ -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
View 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;
}

View 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;

View File

@@ -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);

View File

@@ -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,
},
},
},
});

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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)",

View File

@@ -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"

View File

@@ -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,

View File

@@ -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;
}