Merge branch 'main' into feat/cli-http
This commit is contained in:
commit
7fbe86a88c
32 changed files with 3095 additions and 210 deletions
89
README.md
89
README.md
|
|
@ -4,12 +4,31 @@ The MCP inspector is a developer tool for testing and debugging MCP servers.
|
|||
|
||||

|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The MCP Inspector consists of two main components that work together:
|
||||
|
||||
- **MCP Inspector Client (MCPI)**: A React-based web UI that provides an interactive interface for testing and debugging MCP servers
|
||||
- **MCP Proxy (MCPP)**: A Node.js server that acts as a protocol bridge, connecting the web UI to MCP servers via various transport methods (stdio, SSE, streamable-http)
|
||||
|
||||
Note that the proxy is not a network proxy for intercepting traffic. Instead, it functions as both an MCP client (connecting to your MCP server) and an HTTP server (serving the web UI), enabling browser-based interaction with MCP servers that use different transport protocols.
|
||||
|
||||
## Running the Inspector
|
||||
|
||||
### Requirements
|
||||
|
||||
- Node.js: ^22.7.5
|
||||
|
||||
### Quick Start (UI mode)
|
||||
|
||||
To get up and running right away with the UI, just execute the following:
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector
|
||||
```
|
||||
|
||||
The server will start up and the UI will be accessible at `http://localhost:6274`.
|
||||
|
||||
### From an MCP server repository
|
||||
|
||||
To inspect an MCP server implementation, there's no need to clone this repo. Instead, use `npx`. For example, if your server is built at `build/index.js`:
|
||||
|
|
@ -42,6 +61,74 @@ CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node build
|
|||
|
||||
For more details on ways to use the inspector, see the [Inspector section of the MCP docs site](https://modelcontextprotocol.io/docs/tools/inspector). For help with debugging, see the [Debugging guide](https://modelcontextprotocol.io/docs/tools/debugging).
|
||||
|
||||
### Servers File Export
|
||||
|
||||
The MCP Inspector provides convenient buttons to export server launch configurations for use in clients such as Cursor, Claude Code, or the Inspector's CLI. The file is usually called `mcp.json`.
|
||||
|
||||
- **Server Entry** - Copies a single server configuration entry to your clipboard. This can be added to your `mcp.json` file inside the `mcpServers` object with your preferred server name.
|
||||
|
||||
**STDIO transport example:**
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "node",
|
||||
"args": ["build/index.js", "--debug"],
|
||||
"env": {
|
||||
"API_KEY": "your-api-key",
|
||||
"DEBUG": "true"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**SSE transport example:**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "sse",
|
||||
"url": "http://localhost:3000/events",
|
||||
"note": "For SSE connections, add this URL directly in Client"
|
||||
}
|
||||
```
|
||||
|
||||
- **Servers File** - Copies a complete MCP configuration file structure to your clipboard, with your current server configuration added as `default-server`. This can be saved directly as `mcp.json`.
|
||||
|
||||
**STDIO transport example:**
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"default-server": {
|
||||
"command": "node",
|
||||
"args": ["build/index.js", "--debug"],
|
||||
"env": {
|
||||
"API_KEY": "your-api-key",
|
||||
"DEBUG": "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**SSE transport example:**
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"default-server": {
|
||||
"type": "sse",
|
||||
"url": "http://localhost:3000/events",
|
||||
"note": "For SSE connections, add this URL directly in Client"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
These buttons appear in the Inspector UI after you've configured your server settings, making it easy to save and reuse your configurations.
|
||||
|
||||
For SSE transport connections, the Inspector provides similar functionality for both buttons. The "Server Entry" button copies the SSE URL configuration that can be added to your existing configuration file, while the "Servers File" button creates a complete configuration file containing the SSE URL for direct use in clients.
|
||||
|
||||
You can paste the Server Entry into your existing `mcp.json` file under your chosen server name, or use the complete Servers File payload to create a new configuration file.
|
||||
|
||||
### Authentication
|
||||
|
||||
The inspector supports bearer token authentication for SSE connections. Enter your token in the UI when connecting to an MCP server, and it will be sent in the Authorization header. You can override the header name using the input field in the sidebar.
|
||||
|
|
@ -94,6 +181,8 @@ Example server configuration file:
|
|||
}
|
||||
```
|
||||
|
||||
> **Tip:** You can easily generate this configuration format using the **Server Entry** and **Servers File** buttons in the Inspector UI, as described in the Servers File Export section above.
|
||||
|
||||
You can also set the initial `transport` type, `serverUrl`, `serverCommand`, and `serverArgs` via query params, for example:
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/inspector-cli",
|
||||
"version": "0.12.0",
|
||||
"version": "0.13.0",
|
||||
"description": "CLI for the Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
},
|
||||
"devDependencies": {},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"@modelcontextprotocol/sdk": "^1.11.5",
|
||||
"commander": "^13.1.0",
|
||||
"spawn-rx": "^5.1.2"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/inspector-client",
|
||||
"version": "0.12.0",
|
||||
"version": "0.13.0",
|
||||
"description": "Client-side application for the Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
|
|
@ -23,8 +23,9 @@
|
|||
"test:watch": "jest --config jest.config.cjs --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"@modelcontextprotocol/sdk": "^1.11.5",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"ajv": "^6.12.6",
|
||||
"@radix-ui/react-dialog": "^1.1.3",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ import {
|
|||
Tool,
|
||||
LoggingLevel,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants";
|
||||
import { AuthDebuggerState } from "./lib/auth-types";
|
||||
import { cacheToolOutputSchemas } from "./utils/schemaUtils";
|
||||
import React, {
|
||||
Suspense,
|
||||
useCallback,
|
||||
|
|
@ -28,18 +32,21 @@ import { useConnection } from "./lib/hooks/useConnection";
|
|||
import { useDraggablePane } from "./lib/hooks/useDraggablePane";
|
||||
import { StdErrNotification } from "./lib/notificationTypes";
|
||||
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Bell,
|
||||
Files,
|
||||
FolderTree,
|
||||
Hammer,
|
||||
Hash,
|
||||
Key,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
|
||||
import { z } from "zod";
|
||||
import "./App.css";
|
||||
import AuthDebugger from "./components/AuthDebugger";
|
||||
import ConsoleTab from "./components/ConsoleTab";
|
||||
import HistoryAndNotifications from "./components/History";
|
||||
import PingTab from "./components/PingTab";
|
||||
|
|
@ -111,6 +118,27 @@ const App = () => {
|
|||
}
|
||||
>
|
||||
>([]);
|
||||
const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false);
|
||||
|
||||
// Auth debugger state
|
||||
const [authState, setAuthState] = useState<AuthDebuggerState>({
|
||||
isInitiatingAuth: false,
|
||||
oauthTokens: null,
|
||||
loading: true,
|
||||
oauthStep: "metadata_discovery",
|
||||
oauthMetadata: null,
|
||||
oauthClientInfo: null,
|
||||
authorizationUrl: null,
|
||||
authorizationCode: "",
|
||||
latestError: null,
|
||||
statusMessage: null,
|
||||
validationError: null,
|
||||
});
|
||||
|
||||
// Helper function to update specific auth state properties
|
||||
const updateAuthState = (updates: Partial<AuthDebuggerState>) => {
|
||||
setAuthState((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
const nextRequestId = useRef(0);
|
||||
const rootsRef = useRef<Root[]>([]);
|
||||
|
||||
|
|
@ -207,12 +235,64 @@ const App = () => {
|
|||
const onOAuthConnect = useCallback(
|
||||
(serverUrl: string) => {
|
||||
setSseUrl(serverUrl);
|
||||
setTransportType("sse");
|
||||
setIsAuthDebuggerVisible(false);
|
||||
void connectMcpServer();
|
||||
},
|
||||
[connectMcpServer],
|
||||
);
|
||||
|
||||
// Update OAuth debug state during debug callback
|
||||
const onOAuthDebugConnect = useCallback(
|
||||
({
|
||||
authorizationCode,
|
||||
errorMsg,
|
||||
}: {
|
||||
authorizationCode?: string;
|
||||
errorMsg?: string;
|
||||
}) => {
|
||||
setIsAuthDebuggerVisible(true);
|
||||
if (authorizationCode) {
|
||||
updateAuthState({
|
||||
authorizationCode,
|
||||
oauthStep: "token_request",
|
||||
});
|
||||
}
|
||||
if (errorMsg) {
|
||||
updateAuthState({
|
||||
latestError: new Error(errorMsg),
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Load OAuth tokens when sseUrl changes
|
||||
useEffect(() => {
|
||||
const loadOAuthTokens = async () => {
|
||||
try {
|
||||
if (sseUrl) {
|
||||
const key = getServerSpecificKey(SESSION_KEYS.TOKENS, sseUrl);
|
||||
const tokens = sessionStorage.getItem(key);
|
||||
if (tokens) {
|
||||
const parsedTokens = await OAuthTokensSchema.parseAsync(
|
||||
JSON.parse(tokens),
|
||||
);
|
||||
updateAuthState({
|
||||
oauthTokens: parsedTokens,
|
||||
oauthStep: "complete",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading OAuth tokens:", error);
|
||||
} finally {
|
||||
updateAuthState({ loading: false });
|
||||
}
|
||||
};
|
||||
|
||||
loadOAuthTokens();
|
||||
}, [sseUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${getMCPProxyAddress(config)}/config`)
|
||||
.then((response) => response.json())
|
||||
|
|
@ -394,6 +474,8 @@ const App = () => {
|
|||
);
|
||||
setTools(response.tools);
|
||||
setNextToolCursor(response.nextCursor);
|
||||
// Cache output schemas for validation
|
||||
cacheToolOutputSchemas(response.tools);
|
||||
};
|
||||
|
||||
const callTool = async (name: string, params: Record<string, unknown>) => {
|
||||
|
|
@ -446,6 +528,19 @@ const App = () => {
|
|||
setStdErrNotifications([]);
|
||||
};
|
||||
|
||||
// Helper component for rendering the AuthDebugger
|
||||
const AuthDebuggerWrapper = () => (
|
||||
<TabsContent value="auth">
|
||||
<AuthDebugger
|
||||
serverUrl={sseUrl}
|
||||
onBack={() => setIsAuthDebuggerVisible(false)}
|
||||
authState={authState}
|
||||
updateAuthState={updateAuthState}
|
||||
/>
|
||||
</TabsContent>
|
||||
);
|
||||
|
||||
// Helper function to render OAuth callback components
|
||||
if (window.location.pathname === "/oauth/callback") {
|
||||
const OAuthCallback = React.lazy(
|
||||
() => import("./components/OAuthCallback"),
|
||||
|
|
@ -457,6 +552,17 @@ const App = () => {
|
|||
);
|
||||
}
|
||||
|
||||
if (window.location.pathname === "/oauth/callback/debug") {
|
||||
const OAuthDebugCallback = React.lazy(
|
||||
() => import("./components/OAuthDebugCallback"),
|
||||
);
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<OAuthDebugCallback onConnect={onOAuthDebugConnect} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
<Sidebar
|
||||
|
|
@ -505,7 +611,7 @@ const App = () => {
|
|||
className="w-full p-4"
|
||||
onValueChange={(value) => (window.location.hash = value)}
|
||||
>
|
||||
<TabsList className="mb-4 p-0">
|
||||
<TabsList className="mb-4 py-0">
|
||||
<TabsTrigger
|
||||
value="resources"
|
||||
disabled={!serverCapabilities?.resources}
|
||||
|
|
@ -544,6 +650,10 @@ const App = () => {
|
|||
<FolderTree className="w-4 h-4 mr-2" />
|
||||
Roots
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="auth">
|
||||
<Key className="w-4 h-4 mr-2" />
|
||||
Auth
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="w-full">
|
||||
|
|
@ -552,7 +662,7 @@ const App = () => {
|
|||
!serverCapabilities?.tools ? (
|
||||
<>
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<p className="text-lg text-gray-500">
|
||||
<p className="text-lg text-gray-500 dark:text-gray-400">
|
||||
The connected server does not support any MCP
|
||||
capabilities
|
||||
</p>
|
||||
|
|
@ -652,6 +762,8 @@ const App = () => {
|
|||
clearTools={() => {
|
||||
setTools([]);
|
||||
setNextToolCursor(undefined);
|
||||
// Clear cached output schemas
|
||||
cacheToolOutputSchemas([]);
|
||||
}}
|
||||
callTool={async (name, params) => {
|
||||
clearError("tools");
|
||||
|
|
@ -689,15 +801,36 @@ const App = () => {
|
|||
setRoots={setRoots}
|
||||
onRootsChange={handleRootsChange}
|
||||
/>
|
||||
<AuthDebuggerWrapper />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Tabs>
|
||||
) : isAuthDebuggerVisible ? (
|
||||
<Tabs
|
||||
defaultValue={"auth"}
|
||||
className="w-full p-4"
|
||||
onValueChange={(value) => (window.location.hash = value)}
|
||||
>
|
||||
<AuthDebuggerWrapper />
|
||||
</Tabs>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-lg text-gray-500">
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4">
|
||||
<p className="text-lg text-gray-500 dark:text-gray-400">
|
||||
Connect to an MCP server to start inspecting
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Need to configure authentication?
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsAuthDebuggerVisible(true)}
|
||||
>
|
||||
Open Auth Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -708,7 +841,7 @@ const App = () => {
|
|||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute w-full h-4 -top-2 cursor-row-resize flex items-center justify-center hover:bg-accent/50"
|
||||
className="absolute w-full h-4 -top-2 cursor-row-resize flex items-center justify-center hover:bg-accent/50 dark:hover:bg-input/40"
|
||||
onMouseDown={handleDragStart}
|
||||
>
|
||||
<div className="w-8 h-1 rounded-full bg-border" />
|
||||
|
|
|
|||
288
client/src/components/AuthDebugger.tsx
Normal file
288
client/src/components/AuthDebugger.tsx
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
import { useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DebugInspectorOAuthClientProvider } from "../lib/auth";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { AuthDebuggerState } from "../lib/auth-types";
|
||||
import { OAuthFlowProgress } from "./OAuthFlowProgress";
|
||||
import { OAuthStateMachine } from "../lib/oauth-state-machine";
|
||||
|
||||
export interface AuthDebuggerProps {
|
||||
serverUrl: string;
|
||||
onBack: () => void;
|
||||
authState: AuthDebuggerState;
|
||||
updateAuthState: (updates: Partial<AuthDebuggerState>) => void;
|
||||
}
|
||||
|
||||
interface StatusMessageProps {
|
||||
message: { type: "error" | "success" | "info"; message: string };
|
||||
}
|
||||
|
||||
const StatusMessage = ({ message }: StatusMessageProps) => {
|
||||
let bgColor: string;
|
||||
let textColor: string;
|
||||
let borderColor: string;
|
||||
|
||||
switch (message.type) {
|
||||
case "error":
|
||||
bgColor = "bg-red-50";
|
||||
textColor = "text-red-700";
|
||||
borderColor = "border-red-200";
|
||||
break;
|
||||
case "success":
|
||||
bgColor = "bg-green-50";
|
||||
textColor = "text-green-700";
|
||||
borderColor = "border-green-200";
|
||||
break;
|
||||
case "info":
|
||||
default:
|
||||
bgColor = "bg-blue-50";
|
||||
textColor = "text-blue-700";
|
||||
borderColor = "border-blue-200";
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`p-3 rounded-md border ${bgColor} ${borderColor} ${textColor} mb-4`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<p className="text-sm">{message.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AuthDebugger = ({
|
||||
serverUrl: serverUrl,
|
||||
onBack,
|
||||
authState,
|
||||
updateAuthState,
|
||||
}: AuthDebuggerProps) => {
|
||||
const startOAuthFlow = useCallback(() => {
|
||||
if (!serverUrl) {
|
||||
updateAuthState({
|
||||
statusMessage: {
|
||||
type: "error",
|
||||
message:
|
||||
"Please enter a server URL in the sidebar before authenticating",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateAuthState({
|
||||
oauthStep: "metadata_discovery",
|
||||
authorizationUrl: null,
|
||||
statusMessage: null,
|
||||
latestError: null,
|
||||
});
|
||||
}, [serverUrl, updateAuthState]);
|
||||
|
||||
const stateMachine = useMemo(
|
||||
() => new OAuthStateMachine(serverUrl, updateAuthState),
|
||||
[serverUrl, updateAuthState],
|
||||
);
|
||||
|
||||
const proceedToNextStep = useCallback(async () => {
|
||||
if (!serverUrl) return;
|
||||
|
||||
try {
|
||||
updateAuthState({
|
||||
isInitiatingAuth: true,
|
||||
statusMessage: null,
|
||||
latestError: null,
|
||||
});
|
||||
|
||||
await stateMachine.executeStep(authState);
|
||||
} catch (error) {
|
||||
console.error("OAuth flow error:", error);
|
||||
updateAuthState({
|
||||
latestError: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
} finally {
|
||||
updateAuthState({ isInitiatingAuth: false });
|
||||
}
|
||||
}, [serverUrl, authState, updateAuthState, stateMachine]);
|
||||
|
||||
const handleQuickOAuth = useCallback(async () => {
|
||||
if (!serverUrl) {
|
||||
updateAuthState({
|
||||
statusMessage: {
|
||||
type: "error",
|
||||
message:
|
||||
"Please enter a server URL in the sidebar before authenticating",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateAuthState({ isInitiatingAuth: true, statusMessage: null });
|
||||
try {
|
||||
// Step through the OAuth flow using the state machine instead of the auth() function
|
||||
let currentState: AuthDebuggerState = {
|
||||
...authState,
|
||||
oauthStep: "metadata_discovery",
|
||||
authorizationUrl: null,
|
||||
latestError: null,
|
||||
};
|
||||
|
||||
const oauthMachine = new OAuthStateMachine(serverUrl, (updates) => {
|
||||
// Update our temporary state during the process
|
||||
currentState = { ...currentState, ...updates };
|
||||
// But don't call updateAuthState yet
|
||||
});
|
||||
|
||||
// Manually step through each stage of the OAuth flow
|
||||
while (currentState.oauthStep !== "complete") {
|
||||
await oauthMachine.executeStep(currentState);
|
||||
// In quick mode, we'll just redirect to the authorization URL
|
||||
if (
|
||||
currentState.oauthStep === "authorization_code" &&
|
||||
currentState.authorizationUrl
|
||||
) {
|
||||
// Open the authorization URL automatically
|
||||
window.location.href = currentState.authorizationUrl;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// After the flow completes or reaches a user-input step, update the app state
|
||||
updateAuthState({
|
||||
...currentState,
|
||||
statusMessage: {
|
||||
type: "info",
|
||||
message:
|
||||
currentState.oauthStep === "complete"
|
||||
? "Authentication completed successfully"
|
||||
: "Please complete authentication in the opened window and enter the code",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("OAuth initialization error:", error);
|
||||
updateAuthState({
|
||||
statusMessage: {
|
||||
type: "error",
|
||||
message: `Failed to start OAuth flow: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
updateAuthState({ isInitiatingAuth: false });
|
||||
}
|
||||
}, [serverUrl, updateAuthState, authState]);
|
||||
|
||||
const handleClearOAuth = useCallback(() => {
|
||||
if (serverUrl) {
|
||||
const serverAuthProvider = new DebugInspectorOAuthClientProvider(
|
||||
serverUrl,
|
||||
);
|
||||
serverAuthProvider.clear();
|
||||
updateAuthState({
|
||||
oauthTokens: null,
|
||||
oauthStep: "metadata_discovery",
|
||||
latestError: null,
|
||||
oauthClientInfo: null,
|
||||
authorizationCode: "",
|
||||
validationError: null,
|
||||
oauthMetadata: null,
|
||||
statusMessage: {
|
||||
type: "success",
|
||||
message: "OAuth tokens cleared successfully",
|
||||
},
|
||||
});
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
updateAuthState({ statusMessage: null });
|
||||
}, 3000);
|
||||
}
|
||||
}, [serverUrl, updateAuthState]);
|
||||
|
||||
return (
|
||||
<div className="w-full p-4">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold">Authentication Settings</h2>
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
Back to Connect
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid w-full gap-2">
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Configure authentication settings for your MCP server connection.
|
||||
</p>
|
||||
|
||||
<div className="rounded-md border p-6 space-y-6">
|
||||
<h3 className="text-lg font-medium">OAuth Authentication</h3>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Use OAuth to securely authenticate with the MCP server.
|
||||
</p>
|
||||
|
||||
{authState.statusMessage && (
|
||||
<StatusMessage message={authState.statusMessage} />
|
||||
)}
|
||||
|
||||
{authState.loading ? (
|
||||
<p>Loading authentication status...</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{authState.oauthTokens && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Access Token:</p>
|
||||
<div className="bg-muted p-2 rounded-md text-xs overflow-x-auto">
|
||||
{authState.oauthTokens.access_token.substring(0, 25)}...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={startOAuthFlow}
|
||||
disabled={authState.isInitiatingAuth}
|
||||
>
|
||||
{authState.oauthTokens
|
||||
? "Guided Token Refresh"
|
||||
: "Guided OAuth Flow"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleQuickOAuth}
|
||||
disabled={authState.isInitiatingAuth}
|
||||
>
|
||||
{authState.isInitiatingAuth
|
||||
? "Initiating..."
|
||||
: authState.oauthTokens
|
||||
? "Quick Refresh"
|
||||
: "Quick OAuth Flow"}
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" onClick={handleClearOAuth}>
|
||||
Clear OAuth State
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose "Guided" for step-by-step instructions or "Quick" for
|
||||
the standard automatic flow.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<OAuthFlowProgress
|
||||
serverUrl={serverUrl}
|
||||
authState={authState}
|
||||
updateAuthState={updateAuthState}
|
||||
proceedToNextStep={proceedToNextStep}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthDebugger;
|
||||
|
|
@ -29,7 +29,9 @@ const HistoryAndNotifications = ({
|
|||
<div className="flex-1 overflow-y-auto p-4 border-r">
|
||||
<h2 className="text-lg font-semibold mb-4">History</h2>
|
||||
{requestHistory.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 italic">No history yet</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
No history yet
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{requestHistory
|
||||
|
|
@ -38,7 +40,7 @@ const HistoryAndNotifications = ({
|
|||
.map((request, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="text-sm text-foreground bg-secondary p-2 rounded"
|
||||
className="text-sm text-foreground bg-secondary py-2 px-3 rounded"
|
||||
>
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
|
|
@ -93,7 +95,9 @@ const HistoryAndNotifications = ({
|
|||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<h2 className="text-lg font-semibold mb-4">Server Notifications</h2>
|
||||
{serverNotifications.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 italic">No notifications yet</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
No notifications yet
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{serverNotifications
|
||||
|
|
@ -102,7 +106,7 @@ const HistoryAndNotifications = ({
|
|||
.map((notification, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="text-sm text-foreground bg-secondary p-2 rounded"
|
||||
className="text-sm text-foreground bg-secondary py-2 px-3 rounded"
|
||||
>
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ const ListPane = <T extends object>({
|
|||
buttonText,
|
||||
isButtonDisabled,
|
||||
}: ListPaneProps<T>) => (
|
||||
<div className="bg-card rounded-lg shadow">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<div className="bg-card border border-border rounded-lg shadow">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-border">
|
||||
<h3 className="font-semibold dark:text-white">{title}</h3>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
|
|
@ -46,7 +46,7 @@ const ListPane = <T extends object>({
|
|||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center p-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
|
||||
className="flex items-center py-2 px-4 rounded hover:bg-gray-50 dark:hover:bg-secondary cursor-pointer"
|
||||
onClick={() => setSelectedItem(item)}
|
||||
>
|
||||
{renderItem(item)}
|
||||
|
|
|
|||
92
client/src/components/OAuthDebugCallback.tsx
Normal file
92
client/src/components/OAuthDebugCallback.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { useEffect } from "react";
|
||||
import { SESSION_KEYS } from "../lib/constants";
|
||||
import {
|
||||
generateOAuthErrorDescription,
|
||||
parseOAuthCallbackParams,
|
||||
} from "@/utils/oauthUtils.ts";
|
||||
|
||||
interface OAuthCallbackProps {
|
||||
onConnect: ({
|
||||
authorizationCode,
|
||||
errorMsg,
|
||||
}: {
|
||||
authorizationCode?: string;
|
||||
errorMsg?: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => {
|
||||
useEffect(() => {
|
||||
let isProcessed = false;
|
||||
|
||||
const handleCallback = async () => {
|
||||
// Skip if we've already processed this callback
|
||||
if (isProcessed) {
|
||||
return;
|
||||
}
|
||||
isProcessed = true;
|
||||
|
||||
const params = parseOAuthCallbackParams(window.location.search);
|
||||
if (!params.successful) {
|
||||
const errorMsg = generateOAuthErrorDescription(params);
|
||||
onConnect({ errorMsg });
|
||||
return;
|
||||
}
|
||||
|
||||
const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL);
|
||||
|
||||
// ServerURL isn't set, this can happen if we've opened the
|
||||
// authentication request in a new tab, so we don't have the same
|
||||
// session storage
|
||||
if (!serverUrl) {
|
||||
// If there's no server URL, we're likely in a new tab
|
||||
// Just display the code for manual copying
|
||||
return;
|
||||
}
|
||||
|
||||
if (!params.code) {
|
||||
onConnect({ errorMsg: "Missing authorization code" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Instead of storing in sessionStorage, pass the code directly
|
||||
// to the auth state manager through onConnect
|
||||
onConnect({ authorizationCode: params.code });
|
||||
};
|
||||
|
||||
handleCallback().finally(() => {
|
||||
// Only redirect if we have the URL set, otherwise assume this was
|
||||
// in a new tab
|
||||
if (sessionStorage.getItem(SESSION_KEYS.SERVER_URL)) {
|
||||
window.history.replaceState({}, document.title, "/");
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isProcessed = true;
|
||||
};
|
||||
}, [onConnect]);
|
||||
|
||||
const callbackParams = parseOAuthCallbackParams(window.location.search);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="mt-4 p-4 bg-secondary rounded-md max-w-md">
|
||||
<p className="mb-2 text-sm">
|
||||
Please copy this authorization code and return to the Auth Debugger:
|
||||
</p>
|
||||
<code className="block p-2 bg-muted rounded-sm overflow-x-auto text-xs">
|
||||
{callbackParams.successful && "code" in callbackParams
|
||||
? callbackParams.code
|
||||
: `No code found: ${callbackParams.error}, ${callbackParams.error_description}`}
|
||||
</code>
|
||||
<p className="mt-4 text-xs text-muted-foreground">
|
||||
Close this tab and paste the code in the OAuth flow to complete
|
||||
authentication.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OAuthDebugCallback;
|
||||
294
client/src/components/OAuthFlowProgress.tsx
Normal file
294
client/src/components/OAuthFlowProgress.tsx
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import { AuthDebuggerState, OAuthStep } from "@/lib/auth-types";
|
||||
import { CheckCircle2, Circle, ExternalLink } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { DebugInspectorOAuthClientProvider } from "@/lib/auth";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
|
||||
interface OAuthStepProps {
|
||||
label: string;
|
||||
isComplete: boolean;
|
||||
isCurrent: boolean;
|
||||
error?: Error | null;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const OAuthStepDetails = ({
|
||||
label,
|
||||
isComplete,
|
||||
isCurrent,
|
||||
error,
|
||||
children,
|
||||
}: OAuthStepProps) => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={`flex items-center p-2 rounded-md ${isCurrent ? "bg-accent" : ""}`}
|
||||
>
|
||||
{isComplete ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500 mr-2" />
|
||||
) : (
|
||||
<Circle className="h-5 w-5 text-muted-foreground mr-2" />
|
||||
)}
|
||||
<span className={`${isCurrent ? "font-medium" : ""}`}>{label}</span>
|
||||
</div>
|
||||
|
||||
{/* Show children if current step or complete and children exist */}
|
||||
{(isCurrent || isComplete) && children && (
|
||||
<div className="ml-7 mt-1">{children}</div>
|
||||
)}
|
||||
|
||||
{/* Display error if current step and an error exists */}
|
||||
{isCurrent && error && (
|
||||
<div className="ml-7 mt-2 p-3 border border-red-300 bg-red-50 rounded-md">
|
||||
<p className="text-sm font-medium text-red-700">Error:</p>
|
||||
<p className="text-xs text-red-600 mt-1">{error.message}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface OAuthFlowProgressProps {
|
||||
serverUrl: string;
|
||||
authState: AuthDebuggerState;
|
||||
updateAuthState: (updates: Partial<AuthDebuggerState>) => void;
|
||||
proceedToNextStep: () => Promise<void>;
|
||||
}
|
||||
|
||||
const steps: Array<OAuthStep> = [
|
||||
"metadata_discovery",
|
||||
"client_registration",
|
||||
"authorization_redirect",
|
||||
"authorization_code",
|
||||
"token_request",
|
||||
"complete",
|
||||
];
|
||||
|
||||
export const OAuthFlowProgress = ({
|
||||
serverUrl,
|
||||
authState,
|
||||
updateAuthState,
|
||||
proceedToNextStep,
|
||||
}: OAuthFlowProgressProps) => {
|
||||
const provider = useMemo(
|
||||
() => new DebugInspectorOAuthClientProvider(serverUrl),
|
||||
[serverUrl],
|
||||
);
|
||||
const [clientInfo, setClientInfo] = useState<OAuthClientInformation | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const currentStepIdx = steps.findIndex((s) => s === authState.oauthStep);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchClientInfo = async () => {
|
||||
if (authState.oauthClientInfo) {
|
||||
setClientInfo(authState.oauthClientInfo);
|
||||
} else {
|
||||
try {
|
||||
const info = await provider.clientInformation();
|
||||
if (info) {
|
||||
setClientInfo(info);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch client information:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (currentStepIdx > steps.indexOf("client_registration")) {
|
||||
fetchClientInfo();
|
||||
}
|
||||
}, [
|
||||
provider,
|
||||
authState.oauthStep,
|
||||
authState.oauthClientInfo,
|
||||
currentStepIdx,
|
||||
]);
|
||||
|
||||
// Helper to get step props
|
||||
const getStepProps = (stepName: OAuthStep) => ({
|
||||
isComplete:
|
||||
currentStepIdx > steps.indexOf(stepName) ||
|
||||
currentStepIdx === steps.length - 1, // last step is "complete"
|
||||
isCurrent: authState.oauthStep === stepName,
|
||||
error: authState.oauthStep === stepName ? authState.latestError : null,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-md border p-6 space-y-4 mt-4">
|
||||
<h3 className="text-lg font-medium">OAuth Flow Progress</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Follow these steps to complete OAuth authentication with the server.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<OAuthStepDetails
|
||||
label="Metadata Discovery"
|
||||
{...getStepProps("metadata_discovery")}
|
||||
>
|
||||
{provider.getServerMetadata() && (
|
||||
<details className="text-xs mt-2">
|
||||
<summary className="cursor-pointer text-muted-foreground font-medium">
|
||||
Retrieved OAuth Metadata from {serverUrl}
|
||||
/.well-known/oauth-authorization-server
|
||||
</summary>
|
||||
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
|
||||
{JSON.stringify(provider.getServerMetadata(), null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</OAuthStepDetails>
|
||||
|
||||
<OAuthStepDetails
|
||||
label="Client Registration"
|
||||
{...getStepProps("client_registration")}
|
||||
>
|
||||
{clientInfo && (
|
||||
<details className="text-xs mt-2">
|
||||
<summary className="cursor-pointer text-muted-foreground font-medium">
|
||||
Registered Client Information
|
||||
</summary>
|
||||
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
|
||||
{JSON.stringify(clientInfo, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</OAuthStepDetails>
|
||||
|
||||
<OAuthStepDetails
|
||||
label="Preparing Authorization"
|
||||
{...getStepProps("authorization_redirect")}
|
||||
>
|
||||
{authState.authorizationUrl && (
|
||||
<div className="mt-2 p-3 border rounded-md bg-muted">
|
||||
<p className="font-medium mb-2 text-sm">Authorization URL:</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs break-all">
|
||||
{authState.authorizationUrl}
|
||||
</p>
|
||||
<a
|
||||
href={authState.authorizationUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center text-blue-500 hover:text-blue-700"
|
||||
aria-label="Open authorization URL in new tab"
|
||||
title="Open authorization URL"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Click the link to authorize in your browser. After
|
||||
authorization, you'll be redirected back to continue the flow.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</OAuthStepDetails>
|
||||
|
||||
<OAuthStepDetails
|
||||
label="Request Authorization and acquire authorization code"
|
||||
{...getStepProps("authorization_code")}
|
||||
>
|
||||
<div className="mt-3">
|
||||
<label
|
||||
htmlFor="authCode"
|
||||
className="block text-sm font-medium mb-1"
|
||||
>
|
||||
Authorization Code
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id="authCode"
|
||||
value={authState.authorizationCode}
|
||||
onChange={(e) => {
|
||||
updateAuthState({
|
||||
authorizationCode: e.target.value,
|
||||
validationError: null,
|
||||
});
|
||||
}}
|
||||
placeholder="Enter the code from the authorization server"
|
||||
className={`flex h-9 w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
|
||||
authState.validationError ? "border-red-500" : "border-input"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{authState.validationError && (
|
||||
<p className="text-xs text-red-600 mt-1">
|
||||
{authState.validationError}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Once you've completed authorization in the link, paste the code
|
||||
here.
|
||||
</p>
|
||||
</div>
|
||||
</OAuthStepDetails>
|
||||
|
||||
<OAuthStepDetails
|
||||
label="Token Request"
|
||||
{...getStepProps("token_request")}
|
||||
>
|
||||
{authState.oauthMetadata && (
|
||||
<details className="text-xs mt-2">
|
||||
<summary className="cursor-pointer text-muted-foreground font-medium">
|
||||
Token Request Details
|
||||
</summary>
|
||||
<div className="mt-2 p-2 bg-muted rounded-md">
|
||||
<p className="font-medium">Token Endpoint:</p>
|
||||
<code className="block mt-1 text-xs overflow-x-auto">
|
||||
{authState.oauthMetadata.token_endpoint}
|
||||
</code>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</OAuthStepDetails>
|
||||
|
||||
<OAuthStepDetails
|
||||
label="Authentication Complete"
|
||||
{...getStepProps("complete")}
|
||||
>
|
||||
{authState.oauthTokens && (
|
||||
<details className="text-xs mt-2">
|
||||
<summary className="cursor-pointer text-muted-foreground font-medium">
|
||||
Access Tokens
|
||||
</summary>
|
||||
<p className="mt-1 text-sm">
|
||||
Authentication successful! You can now use the authenticated
|
||||
connection. These tokens will be used automatically for server
|
||||
requests.
|
||||
</p>
|
||||
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
|
||||
{JSON.stringify(authState.oauthTokens, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</OAuthStepDetails>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-4">
|
||||
{authState.oauthStep !== "complete" && (
|
||||
<>
|
||||
<Button
|
||||
onClick={proceedToNextStep}
|
||||
disabled={authState.isInitiatingAuth}
|
||||
>
|
||||
{authState.isInitiatingAuth ? "Processing..." : "Continue"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{authState.oauthStep === "authorization_redirect" &&
|
||||
authState.authorizationUrl && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.open(authState.authorizationUrl!, "_blank")}
|
||||
>
|
||||
Open in New Tab
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -110,8 +110,8 @@ const PromptsTab = ({
|
|||
isButtonDisabled={!nextCursor && prompts.length > 0}
|
||||
/>
|
||||
|
||||
<div className="bg-card rounded-lg shadow">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<div className="bg-card border border-border rounded-lg shadow">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-border">
|
||||
<h3 className="font-semibold">
|
||||
{selectedPrompt ? selectedPrompt.name : "Select a prompt"}
|
||||
</h3>
|
||||
|
|
@ -126,7 +126,7 @@ const PromptsTab = ({
|
|||
) : selectedPrompt ? (
|
||||
<div className="space-y-4">
|
||||
{selectedPrompt.description && (
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{selectedPrompt.description}
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -173,8 +173,8 @@ const ResourcesTab = ({
|
|||
isButtonDisabled={!nextTemplateCursor && resourceTemplates.length > 0}
|
||||
/>
|
||||
|
||||
<div className="bg-card rounded-lg shadow">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-800 flex justify-between items-center">
|
||||
<div className="bg-card border border-border rounded-lg shadow">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-border flex justify-between items-center">
|
||||
<h3
|
||||
className="font-semibold truncate"
|
||||
title={selectedResource?.name || selectedTemplate?.name}
|
||||
|
|
@ -234,7 +234,7 @@ const ResourcesTab = ({
|
|||
/>
|
||||
) : selectedTemplate ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{selectedTemplate.description}
|
||||
</p>
|
||||
{selectedTemplate.uriTemplate
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
Play,
|
||||
ChevronDown,
|
||||
|
|
@ -12,6 +12,8 @@ import {
|
|||
Settings,
|
||||
HelpCircle,
|
||||
RefreshCwOff,
|
||||
Copy,
|
||||
CheckCheck,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -36,6 +38,7 @@ import {
|
|||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useToast } from "../lib/hooks/useToast";
|
||||
|
||||
interface SidebarProps {
|
||||
connectionStatus: ConnectionStatus;
|
||||
|
|
@ -95,10 +98,124 @@ const Sidebar = ({
|
|||
const [showBearerToken, setShowBearerToken] = useState(false);
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
|
||||
const [copiedServerEntry, setCopiedServerEntry] = useState(false);
|
||||
const [copiedServerFile, setCopiedServerFile] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
// Reusable error reporter for copy actions
|
||||
const reportError = useCallback(
|
||||
(error: unknown) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Failed to copy config: ${error instanceof Error ? error.message : String(error)}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
// Shared utility function to generate server config
|
||||
const generateServerConfig = useCallback(() => {
|
||||
if (transportType === "stdio") {
|
||||
return {
|
||||
command,
|
||||
args: args.trim() ? args.split(/\s+/) : [],
|
||||
env: { ...env },
|
||||
};
|
||||
}
|
||||
if (transportType === "sse") {
|
||||
return {
|
||||
type: "sse",
|
||||
url: sseUrl,
|
||||
note: "For SSE connections, add this URL directly in your MCP Client",
|
||||
};
|
||||
}
|
||||
if (transportType === "streamable-http") {
|
||||
return {
|
||||
type: "streamable-http",
|
||||
url: sseUrl,
|
||||
note: "For Streamable HTTP connections, add this URL directly in your MCP Client",
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}, [transportType, command, args, env, sseUrl]);
|
||||
|
||||
// Memoized config entry generator
|
||||
const generateMCPServerEntry = useCallback(() => {
|
||||
return JSON.stringify(generateServerConfig(), null, 4);
|
||||
}, [generateServerConfig]);
|
||||
|
||||
// Memoized config file generator
|
||||
const generateMCPServerFile = useCallback(() => {
|
||||
return JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
"default-server": generateServerConfig(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
4,
|
||||
);
|
||||
}, [generateServerConfig]);
|
||||
|
||||
// Memoized copy handlers
|
||||
const handleCopyServerEntry = useCallback(() => {
|
||||
try {
|
||||
const configJson = generateMCPServerEntry();
|
||||
navigator.clipboard
|
||||
.writeText(configJson)
|
||||
.then(() => {
|
||||
setCopiedServerEntry(true);
|
||||
|
||||
toast({
|
||||
title: "Config entry copied",
|
||||
description:
|
||||
transportType === "stdio"
|
||||
? "Server configuration has been copied to clipboard. Add this to your mcp.json inside the 'mcpServers' object with your preferred server name."
|
||||
: "SSE URL has been copied. Use this URL directly in your MCP Client.",
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
setCopiedServerEntry(false);
|
||||
}, 2000);
|
||||
})
|
||||
.catch((error) => {
|
||||
reportError(error);
|
||||
});
|
||||
} catch (error) {
|
||||
reportError(error);
|
||||
}
|
||||
}, [generateMCPServerEntry, transportType, toast, reportError]);
|
||||
|
||||
const handleCopyServerFile = useCallback(() => {
|
||||
try {
|
||||
const configJson = generateMCPServerFile();
|
||||
navigator.clipboard
|
||||
.writeText(configJson)
|
||||
.then(() => {
|
||||
setCopiedServerFile(true);
|
||||
|
||||
toast({
|
||||
title: "Servers file copied",
|
||||
description:
|
||||
"Servers configuration has been copied to clipboard. Add this to your mcp.json file. Current testing server will be added as 'default-server'",
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
setCopiedServerFile(false);
|
||||
}, 2000);
|
||||
})
|
||||
.catch((error) => {
|
||||
reportError(error);
|
||||
});
|
||||
} catch (error) {
|
||||
reportError(error);
|
||||
}
|
||||
}, [generateMCPServerFile, toast, reportError]);
|
||||
|
||||
return (
|
||||
<div className="w-80 bg-card border-r border-border flex flex-col h-full">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-border">
|
||||
<div className="flex items-center">
|
||||
<h1 className="ml-2 text-lg font-semibold">
|
||||
MCP Inspector v{version}
|
||||
|
|
@ -223,6 +340,7 @@ const Sidebar = ({
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{transportType === "stdio" && (
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
|
|
@ -348,6 +466,46 @@ const Sidebar = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Always show both copy buttons for all transport types */}
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopyServerEntry}
|
||||
className="w-full"
|
||||
>
|
||||
{copiedServerEntry ? (
|
||||
<CheckCheck className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Server Entry
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy Server Entry</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopyServerFile}
|
||||
className="w-full"
|
||||
>
|
||||
{copiedServerFile ? (
|
||||
<CheckCheck className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Servers File
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy Servers File</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
|
|
@ -488,7 +646,7 @@ const Sidebar = ({
|
|||
}
|
||||
})()}`}
|
||||
/>
|
||||
<span className="text-sm text-gray-600">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{(() => {
|
||||
switch (connectionStatus) {
|
||||
case "connected":
|
||||
|
|
|
|||
221
client/src/components/ToolResults.tsx
Normal file
221
client/src/components/ToolResults.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import JsonView from "./JsonView";
|
||||
import {
|
||||
CallToolResultSchema,
|
||||
CompatibilityCallToolResult,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { validateToolOutput, hasOutputSchema } from "@/utils/schemaUtils";
|
||||
|
||||
interface ToolResultsProps {
|
||||
toolResult: CompatibilityCallToolResult | null;
|
||||
selectedTool: Tool | null;
|
||||
}
|
||||
|
||||
const checkContentCompatibility = (
|
||||
structuredContent: unknown,
|
||||
unstructuredContent: Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
[key: string]: unknown;
|
||||
}>,
|
||||
): { isCompatible: boolean; message: string } => {
|
||||
if (
|
||||
unstructuredContent.length !== 1 ||
|
||||
unstructuredContent[0].type !== "text"
|
||||
) {
|
||||
return {
|
||||
isCompatible: false,
|
||||
message: "Unstructured content is not a single text block",
|
||||
};
|
||||
}
|
||||
|
||||
const textContent = unstructuredContent[0].text;
|
||||
if (!textContent) {
|
||||
return {
|
||||
isCompatible: false,
|
||||
message: "Text content is empty",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedContent = JSON.parse(textContent);
|
||||
const isEqual =
|
||||
JSON.stringify(parsedContent) === JSON.stringify(structuredContent);
|
||||
|
||||
if (isEqual) {
|
||||
return {
|
||||
isCompatible: true,
|
||||
message: "Unstructured content matches structured content",
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
isCompatible: false,
|
||||
message: "Parsed JSON does not match structured content",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
isCompatible: false,
|
||||
message: "Unstructured content is not valid JSON",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const ToolResults = ({ toolResult, selectedTool }: ToolResultsProps) => {
|
||||
if (!toolResult) return null;
|
||||
|
||||
if ("content" in toolResult) {
|
||||
const parsedResult = CallToolResultSchema.safeParse(toolResult);
|
||||
if (!parsedResult.success) {
|
||||
return (
|
||||
<>
|
||||
<h4 className="font-semibold mb-2">Invalid Tool Result:</h4>
|
||||
<JsonView data={toolResult} />
|
||||
<h4 className="font-semibold mb-2">Errors:</h4>
|
||||
{parsedResult.error.errors.map((error, idx) => (
|
||||
<JsonView data={error} key={idx} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
const structuredResult = parsedResult.data;
|
||||
const isError = structuredResult.isError ?? false;
|
||||
|
||||
let validationResult = null;
|
||||
const toolHasOutputSchema =
|
||||
selectedTool && hasOutputSchema(selectedTool.name);
|
||||
|
||||
if (toolHasOutputSchema) {
|
||||
if (!structuredResult.structuredContent && !isError) {
|
||||
validationResult = {
|
||||
isValid: false,
|
||||
error:
|
||||
"Tool has an output schema but did not return structured content",
|
||||
};
|
||||
} else if (structuredResult.structuredContent) {
|
||||
validationResult = validateToolOutput(
|
||||
selectedTool.name,
|
||||
structuredResult.structuredContent,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let compatibilityResult = null;
|
||||
if (
|
||||
structuredResult.structuredContent &&
|
||||
structuredResult.content.length > 0 &&
|
||||
selectedTool &&
|
||||
hasOutputSchema(selectedTool.name)
|
||||
) {
|
||||
compatibilityResult = checkContentCompatibility(
|
||||
structuredResult.structuredContent,
|
||||
structuredResult.content,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h4 className="font-semibold mb-2">
|
||||
Tool Result:{" "}
|
||||
{isError ? (
|
||||
<span className="text-red-600 font-semibold">Error</span>
|
||||
) : (
|
||||
<span className="text-green-600 font-semibold">Success</span>
|
||||
)}
|
||||
</h4>
|
||||
{structuredResult.structuredContent && (
|
||||
<div className="mb-4">
|
||||
<h5 className="font-semibold mb-2 text-sm">Structured Content:</h5>
|
||||
<div className="bg-gray-50 dark:bg-gray-900 p-3 rounded-lg">
|
||||
<JsonView data={structuredResult.structuredContent} />
|
||||
{validationResult && (
|
||||
<div
|
||||
className={`mt-2 p-2 rounded text-sm ${
|
||||
validationResult.isValid
|
||||
? "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200"
|
||||
: "bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200"
|
||||
}`}
|
||||
>
|
||||
{validationResult.isValid ? (
|
||||
"✓ Valid according to output schema"
|
||||
) : (
|
||||
<>✗ Validation Error: {validationResult.error}</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!structuredResult.structuredContent &&
|
||||
validationResult &&
|
||||
!validationResult.isValid && (
|
||||
<div className="mb-4">
|
||||
<div className="bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 p-2 rounded text-sm">
|
||||
✗ Validation Error: {validationResult.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{structuredResult.content.length > 0 && (
|
||||
<div className="mb-4">
|
||||
{structuredResult.structuredContent && (
|
||||
<>
|
||||
<h5 className="font-semibold mb-2 text-sm">
|
||||
Unstructured Content:
|
||||
</h5>
|
||||
{compatibilityResult && (
|
||||
<div
|
||||
className={`mb-2 p-2 rounded text-sm ${
|
||||
compatibilityResult.isCompatible
|
||||
? "bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200"
|
||||
: "bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200"
|
||||
}`}
|
||||
>
|
||||
{compatibilityResult.isCompatible ? "✓" : "⚠"}{" "}
|
||||
{compatibilityResult.message}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{structuredResult.content.map((item, index) => (
|
||||
<div key={index} className="mb-2">
|
||||
{item.type === "text" && (
|
||||
<JsonView data={item.text} isError={isError} />
|
||||
)}
|
||||
{item.type === "image" && (
|
||||
<img
|
||||
src={`data:${item.mimeType};base64,${item.data}`}
|
||||
alt="Tool result image"
|
||||
className="max-w-full h-auto"
|
||||
/>
|
||||
)}
|
||||
{item.type === "resource" &&
|
||||
(item.resource?.mimeType?.startsWith("audio/") ? (
|
||||
<audio
|
||||
controls
|
||||
src={`data:${item.resource.mimeType};base64,${item.resource.blob}`}
|
||||
className="w-full"
|
||||
>
|
||||
<p>Your browser does not support audio playback</p>
|
||||
</audio>
|
||||
) : (
|
||||
<JsonView data={item.resource} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else if ("toolResult" in toolResult) {
|
||||
return (
|
||||
<>
|
||||
<h4 className="font-semibold mb-2">Tool Result (Legacy):</h4>
|
||||
<JsonView data={toolResult.toolResult} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ToolResults;
|
||||
|
|
@ -9,15 +9,15 @@ import DynamicJsonForm from "./DynamicJsonForm";
|
|||
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
||||
import { generateDefaultValue } from "@/utils/schemaUtils";
|
||||
import {
|
||||
CallToolResultSchema,
|
||||
CompatibilityCallToolResult,
|
||||
ListToolsResult,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { Loader2, Send } from "lucide-react";
|
||||
import { Loader2, Send, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import ListPane from "./ListPane";
|
||||
import JsonView from "./JsonView";
|
||||
import ToolResults from "./ToolResults";
|
||||
|
||||
const ToolsTab = ({
|
||||
tools,
|
||||
|
|
@ -41,6 +41,7 @@ const ToolsTab = ({
|
|||
}) => {
|
||||
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||
const [isToolRunning, setIsToolRunning] = useState(false);
|
||||
const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const params = Object.entries(
|
||||
|
|
@ -52,75 +53,6 @@ const ToolsTab = ({
|
|||
setParams(Object.fromEntries(params));
|
||||
}, [selectedTool]);
|
||||
|
||||
const renderToolResult = () => {
|
||||
if (!toolResult) return null;
|
||||
|
||||
if ("content" in toolResult) {
|
||||
const parsedResult = CallToolResultSchema.safeParse(toolResult);
|
||||
if (!parsedResult.success) {
|
||||
return (
|
||||
<>
|
||||
<h4 className="font-semibold mb-2">Invalid Tool Result:</h4>
|
||||
<JsonView data={toolResult} />
|
||||
<h4 className="font-semibold mb-2">Errors:</h4>
|
||||
{parsedResult.error.errors.map((error, idx) => (
|
||||
<JsonView data={error} key={idx} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
const structuredResult = parsedResult.data;
|
||||
const isError = structuredResult.isError ?? false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h4 className="font-semibold mb-2">
|
||||
Tool Result:{" "}
|
||||
{isError ? (
|
||||
<span className="text-red-600 font-semibold">Error</span>
|
||||
) : (
|
||||
<span className="text-green-600 font-semibold">Success</span>
|
||||
)}
|
||||
</h4>
|
||||
{structuredResult.content.map((item, index) => (
|
||||
<div key={index} className="mb-2">
|
||||
{item.type === "text" && (
|
||||
<JsonView data={item.text} isError={isError} />
|
||||
)}
|
||||
{item.type === "image" && (
|
||||
<img
|
||||
src={`data:${item.mimeType};base64,${item.data}`}
|
||||
alt="Tool result image"
|
||||
className="max-w-full h-auto"
|
||||
/>
|
||||
)}
|
||||
{item.type === "resource" &&
|
||||
(item.resource?.mimeType?.startsWith("audio/") ? (
|
||||
<audio
|
||||
controls
|
||||
src={`data:${item.resource.mimeType};base64,${item.resource.blob}`}
|
||||
className="w-full"
|
||||
>
|
||||
<p>Your browser does not support audio playback</p>
|
||||
</audio>
|
||||
) : (
|
||||
<JsonView data={item.resource} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
} else if ("toolResult" in toolResult) {
|
||||
return (
|
||||
<>
|
||||
<h4 className="font-semibold mb-2">Tool Result (Legacy):</h4>
|
||||
|
||||
<JsonView data={toolResult.toolResult} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TabsContent value="tools">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
|
@ -145,8 +77,8 @@ const ToolsTab = ({
|
|||
isButtonDisabled={!nextCursor && tools.length > 0}
|
||||
/>
|
||||
|
||||
<div className="bg-card rounded-lg shadow">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<div className="bg-card border border-border rounded-lg shadow">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-border">
|
||||
<h3 className="font-semibold">
|
||||
{selectedTool ? selectedTool.name : "Select a tool"}
|
||||
</h3>
|
||||
|
|
@ -154,7 +86,7 @@ const ToolsTab = ({
|
|||
<div className="p-4">
|
||||
{selectedTool ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{selectedTool.description}
|
||||
</p>
|
||||
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
||||
|
|
@ -164,7 +96,7 @@ const ToolsTab = ({
|
|||
<div key={key}>
|
||||
<Label
|
||||
htmlFor={key}
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{key}
|
||||
</Label>
|
||||
|
|
@ -262,6 +194,42 @@ const ToolsTab = ({
|
|||
);
|
||||
},
|
||||
)}
|
||||
{selectedTool.outputSchema && (
|
||||
<div className="bg-gray-50 dark:bg-gray-900 p-3 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-semibold">Output Schema:</h4>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
setIsOutputSchemaExpanded(!isOutputSchemaExpanded)
|
||||
}
|
||||
className="h-6 px-2"
|
||||
>
|
||||
{isOutputSchemaExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-3 w-3 mr-1" />
|
||||
Collapse
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3 mr-1" />
|
||||
Expand
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={`transition-all ${
|
||||
isOutputSchemaExpanded
|
||||
? ""
|
||||
: "max-h-[8rem] overflow-y-auto"
|
||||
}`}
|
||||
>
|
||||
<JsonView data={selectedTool.outputSchema} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
|
|
@ -285,7 +253,10 @@ const ToolsTab = ({
|
|||
</>
|
||||
)}
|
||||
</Button>
|
||||
{toolResult && renderToolResult()}
|
||||
<ToolResults
|
||||
toolResult={toolResult}
|
||||
selectedTool={selectedTool}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
|
|
|
|||
435
client/src/components/__tests__/AuthDebugger.test.tsx
Normal file
435
client/src/components/__tests__/AuthDebugger.test.tsx
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
act,
|
||||
} from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { describe, it, beforeEach, jest } from "@jest/globals";
|
||||
import AuthDebugger, { AuthDebuggerProps } from "../AuthDebugger";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { SESSION_KEYS } from "@/lib/constants";
|
||||
|
||||
const mockOAuthTokens = {
|
||||
access_token: "test_access_token",
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
refresh_token: "test_refresh_token",
|
||||
scope: "test_scope",
|
||||
};
|
||||
|
||||
const mockOAuthMetadata = {
|
||||
issuer: "https://oauth.example.com",
|
||||
authorization_endpoint: "https://oauth.example.com/authorize",
|
||||
token_endpoint: "https://oauth.example.com/token",
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code"],
|
||||
};
|
||||
|
||||
const mockOAuthClientInfo = {
|
||||
client_id: "test_client_id",
|
||||
client_secret: "test_client_secret",
|
||||
redirect_uris: ["http://localhost:3000/oauth/callback/debug"],
|
||||
};
|
||||
|
||||
// Mock MCP SDK functions - must be before imports
|
||||
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
||||
auth: jest.fn(),
|
||||
discoverOAuthMetadata: jest.fn(),
|
||||
registerClient: jest.fn(),
|
||||
startAuthorization: jest.fn(),
|
||||
exchangeAuthorization: jest.fn(),
|
||||
}));
|
||||
|
||||
// Import the functions to get their types
|
||||
import {
|
||||
discoverOAuthMetadata,
|
||||
registerClient,
|
||||
startAuthorization,
|
||||
exchangeAuthorization,
|
||||
auth,
|
||||
} from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
|
||||
// Type the mocked functions properly
|
||||
const mockDiscoverOAuthMetadata = discoverOAuthMetadata as jest.MockedFunction<
|
||||
typeof discoverOAuthMetadata
|
||||
>;
|
||||
const mockRegisterClient = registerClient as jest.MockedFunction<
|
||||
typeof registerClient
|
||||
>;
|
||||
const mockStartAuthorization = startAuthorization as jest.MockedFunction<
|
||||
typeof startAuthorization
|
||||
>;
|
||||
const mockExchangeAuthorization = exchangeAuthorization as jest.MockedFunction<
|
||||
typeof exchangeAuthorization
|
||||
>;
|
||||
const mockAuth = auth as jest.MockedFunction<typeof auth>;
|
||||
|
||||
const sessionStorageMock = {
|
||||
getItem: jest.fn(),
|
||||
setItem: jest.fn(),
|
||||
removeItem: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
};
|
||||
Object.defineProperty(window, "sessionStorage", {
|
||||
value: sessionStorageMock,
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "location", {
|
||||
value: {
|
||||
origin: "http://localhost:3000",
|
||||
},
|
||||
});
|
||||
|
||||
describe("AuthDebugger", () => {
|
||||
const defaultAuthState = {
|
||||
isInitiatingAuth: false,
|
||||
oauthTokens: null,
|
||||
loading: false,
|
||||
oauthStep: "metadata_discovery" as const,
|
||||
oauthMetadata: null,
|
||||
oauthClientInfo: null,
|
||||
authorizationUrl: null,
|
||||
authorizationCode: "",
|
||||
latestError: null,
|
||||
statusMessage: null,
|
||||
validationError: null,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
serverUrl: "https://example.com",
|
||||
onBack: jest.fn(),
|
||||
authState: defaultAuthState,
|
||||
updateAuthState: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
sessionStorageMock.getItem.mockReturnValue(null);
|
||||
|
||||
mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata);
|
||||
mockRegisterClient.mockResolvedValue(mockOAuthClientInfo);
|
||||
mockStartAuthorization.mockImplementation(async (_sseUrl, options) => {
|
||||
const authUrl = new URL("https://oauth.example.com/authorize");
|
||||
|
||||
if (options.scope) {
|
||||
authUrl.searchParams.set("scope", options.scope);
|
||||
}
|
||||
|
||||
return {
|
||||
authorizationUrl: authUrl,
|
||||
codeVerifier: "test_verifier",
|
||||
};
|
||||
});
|
||||
mockExchangeAuthorization.mockResolvedValue(mockOAuthTokens);
|
||||
});
|
||||
|
||||
const renderAuthDebugger = (props: Partial<AuthDebuggerProps> = {}) => {
|
||||
const mergedProps = {
|
||||
...defaultProps,
|
||||
...props,
|
||||
authState: { ...defaultAuthState, ...(props.authState || {}) },
|
||||
};
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<AuthDebugger {...mergedProps} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("Initial Rendering", () => {
|
||||
it("should render the component with correct title", async () => {
|
||||
await act(async () => {
|
||||
renderAuthDebugger();
|
||||
});
|
||||
expect(screen.getByText("Authentication Settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onBack when Back button is clicked", async () => {
|
||||
const onBack = jest.fn();
|
||||
await act(async () => {
|
||||
renderAuthDebugger({ onBack });
|
||||
});
|
||||
fireEvent.click(screen.getByText("Back to Connect"));
|
||||
expect(onBack).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuth Flow", () => {
|
||||
it("should start OAuth flow when 'Guided OAuth Flow' is clicked", async () => {
|
||||
await act(async () => {
|
||||
renderAuthDebugger();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("Guided OAuth Flow"));
|
||||
});
|
||||
|
||||
expect(screen.getByText("OAuth Flow Progress")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show error when OAuth flow is started without sseUrl", async () => {
|
||||
const updateAuthState = jest.fn();
|
||||
await act(async () => {
|
||||
renderAuthDebugger({ serverUrl: "", updateAuthState });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("Guided OAuth Flow"));
|
||||
});
|
||||
|
||||
expect(updateAuthState).toHaveBeenCalledWith({
|
||||
statusMessage: {
|
||||
type: "error",
|
||||
message:
|
||||
"Please enter a server URL in the sidebar before authenticating",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should start quick OAuth flow and properly fetch and save metadata", async () => {
|
||||
// Setup the auth mock
|
||||
mockAuth.mockResolvedValue("AUTHORIZED");
|
||||
|
||||
const updateAuthState = jest.fn();
|
||||
await act(async () => {
|
||||
renderAuthDebugger({ updateAuthState });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("Quick OAuth Flow"));
|
||||
});
|
||||
|
||||
// Should first discover and save OAuth metadata
|
||||
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
|
||||
"https://example.com",
|
||||
);
|
||||
|
||||
// Check that updateAuthState was called with the right info message
|
||||
expect(updateAuthState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
oauthStep: "authorization_code",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should show error when quick OAuth flow fails to discover metadata", async () => {
|
||||
mockDiscoverOAuthMetadata.mockRejectedValue(
|
||||
new Error("Metadata discovery failed"),
|
||||
);
|
||||
|
||||
const updateAuthState = jest.fn();
|
||||
await act(async () => {
|
||||
renderAuthDebugger({ updateAuthState });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("Quick OAuth Flow"));
|
||||
});
|
||||
|
||||
// Check that updateAuthState was called with an error message
|
||||
expect(updateAuthState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
statusMessage: {
|
||||
type: "error",
|
||||
message: expect.stringContaining("Failed to start OAuth flow"),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Session Storage Integration", () => {
|
||||
it("should load OAuth tokens from session storage", async () => {
|
||||
// Mock the specific key for tokens with server URL
|
||||
sessionStorageMock.getItem.mockImplementation((key) => {
|
||||
if (key === "[https://example.com] mcp_tokens") {
|
||||
return JSON.stringify(mockOAuthTokens);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renderAuthDebugger({
|
||||
authState: {
|
||||
...defaultAuthState,
|
||||
oauthTokens: mockOAuthTokens,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Access Token:/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle errors loading OAuth tokens from session storage", async () => {
|
||||
// Mock console to avoid cluttering test output
|
||||
const originalError = console.error;
|
||||
console.error = jest.fn();
|
||||
|
||||
// Mock getItem to return invalid JSON for tokens
|
||||
sessionStorageMock.getItem.mockImplementation((key) => {
|
||||
if (key === "[https://example.com] mcp_tokens") {
|
||||
return "invalid json";
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renderAuthDebugger();
|
||||
});
|
||||
|
||||
// Component should still render despite the error
|
||||
expect(screen.getByText("Authentication Settings")).toBeInTheDocument();
|
||||
|
||||
// Restore console.error
|
||||
console.error = originalError;
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuth State Management", () => {
|
||||
it("should clear OAuth state when Clear button is clicked", async () => {
|
||||
const updateAuthState = jest.fn();
|
||||
// Mock the session storage to return tokens for the specific key
|
||||
sessionStorageMock.getItem.mockImplementation((key) => {
|
||||
if (key === "[https://example.com] mcp_tokens") {
|
||||
return JSON.stringify(mockOAuthTokens);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renderAuthDebugger({
|
||||
authState: {
|
||||
...defaultAuthState,
|
||||
oauthTokens: mockOAuthTokens,
|
||||
},
|
||||
updateAuthState,
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("Clear OAuth State"));
|
||||
});
|
||||
|
||||
expect(updateAuthState).toHaveBeenCalledWith({
|
||||
oauthTokens: null,
|
||||
oauthStep: "metadata_discovery",
|
||||
latestError: null,
|
||||
oauthClientInfo: null,
|
||||
oauthMetadata: null,
|
||||
authorizationCode: "",
|
||||
validationError: null,
|
||||
statusMessage: {
|
||||
type: "success",
|
||||
message: "OAuth tokens cleared successfully",
|
||||
},
|
||||
});
|
||||
|
||||
// Verify session storage was cleared
|
||||
expect(sessionStorageMock.removeItem).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuth Flow Steps", () => {
|
||||
it("should handle OAuth flow step progression", async () => {
|
||||
const updateAuthState = jest.fn();
|
||||
await act(async () => {
|
||||
renderAuthDebugger({
|
||||
updateAuthState,
|
||||
authState: {
|
||||
...defaultAuthState,
|
||||
isInitiatingAuth: false, // Changed to false so button is enabled
|
||||
oauthStep: "metadata_discovery",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Verify metadata discovery step
|
||||
expect(screen.getByText("Metadata Discovery")).toBeInTheDocument();
|
||||
|
||||
// Click Continue - this should trigger metadata discovery
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("Continue"));
|
||||
});
|
||||
|
||||
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
|
||||
"https://example.com",
|
||||
);
|
||||
});
|
||||
|
||||
// Setup helper for OAuth authorization tests
|
||||
const setupAuthorizationUrlTest = async (metadata: OAuthMetadata) => {
|
||||
const updateAuthState = jest.fn();
|
||||
|
||||
// Mock the session storage to return metadata
|
||||
sessionStorageMock.getItem.mockImplementation((key) => {
|
||||
if (key === `[https://example.com] ${SESSION_KEYS.SERVER_METADATA}`) {
|
||||
return JSON.stringify(metadata);
|
||||
}
|
||||
if (
|
||||
key === `[https://example.com] ${SESSION_KEYS.CLIENT_INFORMATION}`
|
||||
) {
|
||||
return JSON.stringify(mockOAuthClientInfo);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renderAuthDebugger({
|
||||
updateAuthState,
|
||||
authState: {
|
||||
...defaultAuthState,
|
||||
isInitiatingAuth: false,
|
||||
oauthStep: "authorization_redirect",
|
||||
oauthMetadata: metadata,
|
||||
oauthClientInfo: mockOAuthClientInfo,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Click Continue to trigger authorization
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("Continue"));
|
||||
});
|
||||
|
||||
return updateAuthState;
|
||||
};
|
||||
|
||||
it("should include scope in authorization URL when scopes_supported is present", async () => {
|
||||
const metadataWithScopes = {
|
||||
...mockOAuthMetadata,
|
||||
scopes_supported: ["read", "write", "admin"],
|
||||
};
|
||||
|
||||
const updateAuthState =
|
||||
await setupAuthorizationUrlTest(metadataWithScopes);
|
||||
|
||||
// Wait for the updateAuthState to be called
|
||||
await waitFor(() => {
|
||||
expect(updateAuthState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authorizationUrl: expect.stringContaining("scope="),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not include scope in authorization URL when scopes_supported is not present", async () => {
|
||||
const updateAuthState =
|
||||
await setupAuthorizationUrlTest(mockOAuthMetadata);
|
||||
|
||||
// Wait for the updateAuthState to be called
|
||||
await waitFor(() => {
|
||||
expect(updateAuthState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authorizationUrl: expect.not.stringContaining("scope="),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { describe, it, beforeEach, jest } from "@jest/globals";
|
||||
import Sidebar from "../Sidebar";
|
||||
|
|
@ -12,6 +12,25 @@ jest.mock("../../lib/hooks/useTheme", () => ({
|
|||
default: () => ["light", jest.fn()],
|
||||
}));
|
||||
|
||||
// Mock toast hook
|
||||
const mockToast = jest.fn();
|
||||
jest.mock("@/lib/hooks/useToast", () => ({
|
||||
useToast: () => ({
|
||||
toast: mockToast,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock navigator clipboard
|
||||
const mockClipboardWrite = jest.fn(() => Promise.resolve());
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: {
|
||||
writeText: mockClipboardWrite,
|
||||
},
|
||||
});
|
||||
|
||||
// Setup fake timers
|
||||
jest.useFakeTimers();
|
||||
|
||||
describe("Sidebar Environment Variables", () => {
|
||||
const defaultProps = {
|
||||
connectionStatus: "disconnected" as const,
|
||||
|
|
@ -53,6 +72,7 @@ describe("Sidebar Environment Variables", () => {
|
|||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.clearAllTimers();
|
||||
});
|
||||
|
||||
describe("Basic Operations", () => {
|
||||
|
|
@ -622,4 +642,231 @@ describe("Sidebar Environment Variables", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Copy Configuration Features", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.clearAllTimers();
|
||||
});
|
||||
|
||||
const getCopyButtons = () => {
|
||||
return {
|
||||
serverEntry: screen.getByRole("button", { name: /server entry/i }),
|
||||
serversFile: screen.getByRole("button", { name: /servers file/i }),
|
||||
};
|
||||
};
|
||||
|
||||
it("should render both copy buttons for all transport types", () => {
|
||||
["stdio", "sse", "streamable-http"].forEach((transportType) => {
|
||||
renderSidebar({ transportType });
|
||||
// There should be exactly one Server Entry and one Servers File button per render
|
||||
const serverEntryButtons = screen.getAllByRole("button", {
|
||||
name: /server entry/i,
|
||||
});
|
||||
const serversFileButtons = screen.getAllByRole("button", {
|
||||
name: /servers file/i,
|
||||
});
|
||||
expect(serverEntryButtons).toHaveLength(1);
|
||||
expect(serversFileButtons).toHaveLength(1);
|
||||
// Clean up DOM for next iteration
|
||||
// (Testing Library's render does not auto-unmount in a loop)
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
});
|
||||
|
||||
it("should copy server entry configuration to clipboard for STDIO transport", async () => {
|
||||
const command = "node";
|
||||
const args = "--inspect server.js";
|
||||
const env = { API_KEY: "test-key", DEBUG: "true" };
|
||||
|
||||
renderSidebar({
|
||||
transportType: "stdio",
|
||||
command,
|
||||
args,
|
||||
env,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const { serverEntry } = getCopyButtons();
|
||||
fireEvent.click(serverEntry);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
|
||||
const expectedConfig = JSON.stringify(
|
||||
{
|
||||
command,
|
||||
args: ["--inspect", "server.js"],
|
||||
env,
|
||||
},
|
||||
null,
|
||||
4,
|
||||
);
|
||||
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
||||
});
|
||||
|
||||
it("should copy servers file configuration to clipboard for STDIO transport", async () => {
|
||||
const command = "node";
|
||||
const args = "--inspect server.js";
|
||||
const env = { API_KEY: "test-key", DEBUG: "true" };
|
||||
|
||||
renderSidebar({
|
||||
transportType: "stdio",
|
||||
command,
|
||||
args,
|
||||
env,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const { serversFile } = getCopyButtons();
|
||||
fireEvent.click(serversFile);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
|
||||
const expectedConfig = JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
"default-server": {
|
||||
command,
|
||||
args: ["--inspect", "server.js"],
|
||||
env,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
4,
|
||||
);
|
||||
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
||||
});
|
||||
|
||||
it("should copy server entry configuration to clipboard for SSE transport", async () => {
|
||||
const sseUrl = "http://localhost:3000/events";
|
||||
renderSidebar({ transportType: "sse", sseUrl });
|
||||
|
||||
await act(async () => {
|
||||
const { serverEntry } = getCopyButtons();
|
||||
fireEvent.click(serverEntry);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
|
||||
const expectedConfig = JSON.stringify(
|
||||
{
|
||||
type: "sse",
|
||||
url: sseUrl,
|
||||
note: "For SSE connections, add this URL directly in your MCP Client",
|
||||
},
|
||||
null,
|
||||
4,
|
||||
);
|
||||
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
||||
});
|
||||
|
||||
it("should copy servers file configuration to clipboard for SSE transport", async () => {
|
||||
const sseUrl = "http://localhost:3000/events";
|
||||
renderSidebar({ transportType: "sse", sseUrl });
|
||||
|
||||
await act(async () => {
|
||||
const { serversFile } = getCopyButtons();
|
||||
fireEvent.click(serversFile);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
|
||||
const expectedConfig = JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
"default-server": {
|
||||
type: "sse",
|
||||
url: sseUrl,
|
||||
note: "For SSE connections, add this URL directly in your MCP Client",
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
4,
|
||||
);
|
||||
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
||||
});
|
||||
|
||||
it("should copy server entry configuration to clipboard for streamable-http transport", async () => {
|
||||
const sseUrl = "http://localhost:3001/sse";
|
||||
renderSidebar({ transportType: "streamable-http", sseUrl });
|
||||
|
||||
await act(async () => {
|
||||
const { serverEntry } = getCopyButtons();
|
||||
fireEvent.click(serverEntry);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
|
||||
const expectedConfig = JSON.stringify(
|
||||
{
|
||||
type: "streamable-http",
|
||||
url: sseUrl,
|
||||
note: "For Streamable HTTP connections, add this URL directly in your MCP Client",
|
||||
},
|
||||
null,
|
||||
4,
|
||||
);
|
||||
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
||||
});
|
||||
|
||||
it("should copy servers file configuration to clipboard for streamable-http transport", async () => {
|
||||
const sseUrl = "http://localhost:3001/sse";
|
||||
renderSidebar({ transportType: "streamable-http", sseUrl });
|
||||
|
||||
await act(async () => {
|
||||
const { serversFile } = getCopyButtons();
|
||||
fireEvent.click(serversFile);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
|
||||
const expectedConfig = JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
"default-server": {
|
||||
type: "streamable-http",
|
||||
url: sseUrl,
|
||||
note: "For Streamable HTTP connections, add this URL directly in your MCP Client",
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
4,
|
||||
);
|
||||
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
||||
});
|
||||
|
||||
it("should handle empty args in STDIO transport", async () => {
|
||||
const command = "python";
|
||||
const args = "";
|
||||
|
||||
renderSidebar({
|
||||
transportType: "stdio",
|
||||
command,
|
||||
args,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const { serverEntry } = getCopyButtons();
|
||||
fireEvent.click(serverEntry);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
|
||||
const expectedConfig = JSON.stringify(
|
||||
{
|
||||
command,
|
||||
args: [],
|
||||
env: {},
|
||||
},
|
||||
null,
|
||||
4,
|
||||
);
|
||||
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { describe, it, expect, jest } from "@jest/globals";
|
||||
import "@testing-library/jest-dom";
|
||||
import { describe, it, jest, beforeEach } from "@jest/globals";
|
||||
import ToolsTab from "../ToolsTab";
|
||||
import { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { cacheToolOutputSchemas } from "@/utils/schemaUtils";
|
||||
|
||||
describe("ToolsTab", () => {
|
||||
beforeEach(() => {
|
||||
// Clear the output schema cache before each test
|
||||
cacheToolOutputSchemas([]);
|
||||
});
|
||||
|
||||
const mockTools: Tool[] = [
|
||||
{
|
||||
name: "tool1",
|
||||
|
|
@ -141,4 +147,217 @@ describe("ToolsTab", () => {
|
|||
|
||||
expect(submitButton.getAttribute("disabled")).toBeNull();
|
||||
});
|
||||
|
||||
describe("Output Schema Display", () => {
|
||||
const toolWithOutputSchema: Tool = {
|
||||
name: "weatherTool",
|
||||
description: "Get weather",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
city: { type: "string" as const },
|
||||
},
|
||||
},
|
||||
outputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
temperature: { type: "number" as const },
|
||||
humidity: { type: "number" as const },
|
||||
},
|
||||
required: ["temperature", "humidity"],
|
||||
},
|
||||
};
|
||||
|
||||
it("should display output schema when tool has one", () => {
|
||||
renderToolsTab({
|
||||
tools: [toolWithOutputSchema],
|
||||
selectedTool: toolWithOutputSchema,
|
||||
});
|
||||
|
||||
expect(screen.getByText("Output Schema:")).toBeInTheDocument();
|
||||
// Check for expand/collapse button
|
||||
expect(
|
||||
screen.getByRole("button", { name: /expand/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display output schema section when tool doesn't have one", () => {
|
||||
renderToolsTab({
|
||||
selectedTool: mockTools[0], // Tool without outputSchema
|
||||
});
|
||||
|
||||
expect(screen.queryByText("Output Schema:")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should toggle output schema expansion", () => {
|
||||
renderToolsTab({
|
||||
tools: [toolWithOutputSchema],
|
||||
selectedTool: toolWithOutputSchema,
|
||||
});
|
||||
|
||||
const toggleButton = screen.getByRole("button", { name: /expand/i });
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(toggleButton);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /collapse/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Click to collapse
|
||||
fireEvent.click(toggleButton);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /expand/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Structured Output Results", () => {
|
||||
const toolWithOutputSchema: Tool = {
|
||||
name: "weatherTool",
|
||||
description: "Get weather",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {},
|
||||
},
|
||||
outputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
temperature: { type: "number" as const },
|
||||
},
|
||||
required: ["temperature"],
|
||||
},
|
||||
};
|
||||
|
||||
it("should display structured content when present", () => {
|
||||
// Cache the tool's output schema so hasOutputSchema returns true
|
||||
cacheToolOutputSchemas([toolWithOutputSchema]);
|
||||
|
||||
const structuredResult = {
|
||||
content: [],
|
||||
structuredContent: {
|
||||
temperature: 25,
|
||||
},
|
||||
};
|
||||
|
||||
renderToolsTab({
|
||||
selectedTool: toolWithOutputSchema,
|
||||
toolResult: structuredResult,
|
||||
});
|
||||
|
||||
expect(screen.getByText("Structured Content:")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Valid according to output schema/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show validation error for invalid structured content", () => {
|
||||
cacheToolOutputSchemas([toolWithOutputSchema]);
|
||||
|
||||
const invalidResult = {
|
||||
content: [],
|
||||
structuredContent: {
|
||||
temperature: "25", // String instead of number
|
||||
},
|
||||
};
|
||||
|
||||
renderToolsTab({
|
||||
selectedTool: toolWithOutputSchema,
|
||||
toolResult: invalidResult,
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Validation Error:/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show error when tool with output schema doesn't return structured content", () => {
|
||||
cacheToolOutputSchemas([toolWithOutputSchema]);
|
||||
|
||||
const resultWithoutStructured = {
|
||||
content: [{ type: "text", text: "some result" }],
|
||||
// No structuredContent
|
||||
};
|
||||
|
||||
renderToolsTab({
|
||||
selectedTool: toolWithOutputSchema,
|
||||
toolResult: resultWithoutStructured,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Tool has an output schema but did not return structured content/,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show unstructured content title when both structured and unstructured exist", () => {
|
||||
cacheToolOutputSchemas([toolWithOutputSchema]);
|
||||
|
||||
const resultWithBoth = {
|
||||
content: [{ type: "text", text: '{"temperature": 25}' }],
|
||||
structuredContent: { temperature: 25 },
|
||||
};
|
||||
|
||||
renderToolsTab({
|
||||
selectedTool: toolWithOutputSchema,
|
||||
toolResult: resultWithBoth,
|
||||
});
|
||||
|
||||
expect(screen.getByText("Structured Content:")).toBeInTheDocument();
|
||||
expect(screen.getByText("Unstructured Content:")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show unstructured content title when only unstructured exists", () => {
|
||||
const resultWithUnstructuredOnly = {
|
||||
content: [{ type: "text", text: "some result" }],
|
||||
};
|
||||
|
||||
renderToolsTab({
|
||||
selectedTool: mockTools[0], // Tool without output schema
|
||||
toolResult: resultWithUnstructuredOnly,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByText("Unstructured Content:"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show compatibility check when tool has output schema", () => {
|
||||
cacheToolOutputSchemas([toolWithOutputSchema]);
|
||||
|
||||
const compatibleResult = {
|
||||
content: [{ type: "text", text: '{"temperature": 25}' }],
|
||||
structuredContent: { temperature: 25 },
|
||||
};
|
||||
|
||||
renderToolsTab({
|
||||
selectedTool: toolWithOutputSchema,
|
||||
toolResult: compatibleResult,
|
||||
});
|
||||
|
||||
// Should show compatibility result
|
||||
expect(
|
||||
screen.getByText(
|
||||
/matches structured content|not a single text block|not valid JSON|does not match/,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show compatibility check when tool has no output schema", () => {
|
||||
const resultWithBoth = {
|
||||
content: [{ type: "text", text: '{"data": "value"}' }],
|
||||
structuredContent: { different: "data" },
|
||||
};
|
||||
|
||||
renderToolsTab({
|
||||
selectedTool: mockTools[0], // Tool without output schema
|
||||
toolResult: resultWithBoth,
|
||||
});
|
||||
|
||||
// Should not show any compatibility messages
|
||||
expect(
|
||||
screen.queryByText(
|
||||
/matches structured content|not a single text block|not valid JSON|does not match/,
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -93,8 +93,8 @@ h1 {
|
|||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--border: 217.2 24% 24%;
|
||||
--input: 217.2 24% 24%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
|
|
|
|||
38
client/src/lib/auth-types.ts
Normal file
38
client/src/lib/auth-types.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import {
|
||||
OAuthMetadata,
|
||||
OAuthClientInformationFull,
|
||||
OAuthClientInformation,
|
||||
OAuthTokens,
|
||||
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
|
||||
// OAuth flow steps
|
||||
export type OAuthStep =
|
||||
| "metadata_discovery"
|
||||
| "client_registration"
|
||||
| "authorization_redirect"
|
||||
| "authorization_code"
|
||||
| "token_request"
|
||||
| "complete";
|
||||
|
||||
// Message types for inline feedback
|
||||
export type MessageType = "success" | "error" | "info";
|
||||
|
||||
export interface StatusMessage {
|
||||
type: MessageType;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Single state interface for OAuth state
|
||||
export interface AuthDebuggerState {
|
||||
isInitiatingAuth: boolean;
|
||||
oauthTokens: OAuthTokens | null;
|
||||
loading: boolean;
|
||||
oauthStep: OAuthStep;
|
||||
oauthMetadata: OAuthMetadata | null;
|
||||
oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null;
|
||||
authorizationUrl: string | null;
|
||||
authorizationCode: string;
|
||||
latestError: Error | null;
|
||||
statusMessage: StatusMessage | null;
|
||||
validationError: string | null;
|
||||
}
|
||||
|
|
@ -4,11 +4,13 @@ import {
|
|||
OAuthClientInformation,
|
||||
OAuthTokens,
|
||||
OAuthTokensSchema,
|
||||
OAuthClientMetadata,
|
||||
OAuthMetadata,
|
||||
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import { SESSION_KEYS, getServerSpecificKey } from "./constants";
|
||||
|
||||
export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||
constructor(private serverUrl: string) {
|
||||
constructor(public serverUrl: string) {
|
||||
// Save the server URL to session storage
|
||||
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl);
|
||||
}
|
||||
|
|
@ -17,7 +19,7 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
|||
return window.location.origin + "/oauth/callback";
|
||||
}
|
||||
|
||||
get clientMetadata() {
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
return {
|
||||
redirect_uris: [this.redirectUrl],
|
||||
token_endpoint_auth_method: "none",
|
||||
|
|
@ -101,3 +103,38 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Overrides debug URL and allows saving server OAuth metadata to
|
||||
// display in debug UI.
|
||||
export class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider {
|
||||
get redirectUrl(): string {
|
||||
return `${window.location.origin}/oauth/callback/debug`;
|
||||
}
|
||||
|
||||
saveServerMetadata(metadata: OAuthMetadata) {
|
||||
const key = getServerSpecificKey(
|
||||
SESSION_KEYS.SERVER_METADATA,
|
||||
this.serverUrl,
|
||||
);
|
||||
sessionStorage.setItem(key, JSON.stringify(metadata));
|
||||
}
|
||||
|
||||
getServerMetadata(): OAuthMetadata | null {
|
||||
const key = getServerSpecificKey(
|
||||
SESSION_KEYS.SERVER_METADATA,
|
||||
this.serverUrl,
|
||||
);
|
||||
const metadata = sessionStorage.getItem(key);
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(metadata);
|
||||
}
|
||||
|
||||
clear() {
|
||||
super.clear();
|
||||
sessionStorage.removeItem(
|
||||
getServerSpecificKey(SESSION_KEYS.SERVER_METADATA, this.serverUrl),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue