Merge branch 'modelcontextprotocol:main' into main

This commit is contained in:
Aaron Powell 2025-06-05 10:18:42 +10:00 • committed by GitHub
commit 676d454457
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 7213 additions and 633 deletions

View file

@ -26,6 +26,10 @@ jobs:
# - run: npm ci
- run: npm install --no-package-lock
- name: Check linting
working-directory: ./client
run: npm run lint
- name: Run client tests
working-directory: ./client
run: npm test
@ -54,8 +58,6 @@ jobs:
# - run: npm ci
- run: npm install --no-package-lock
- run: npm run build
# TODO: Add --provenance once the repo is public
- run: npm run publish-all
env:

118
README.md
View file

@ -4,12 +4,31 @@ The MCP inspector is a developer tool for testing and debugging MCP servers.
![MCP Inspector Screenshot](https://raw.githubusercontent.com/modelcontextprotocol/inspector/main/mcp-inspector.png)
## 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.
@ -54,12 +141,13 @@ The MCP Inspector includes a proxy server that can run and communicate with loca
The MCP Inspector supports the following configuration settings. To change them, click on the `Configuration` button in the MCP Inspector UI:
| Setting | Description | Default |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------- |
| `MCP_SERVER_REQUEST_TIMEOUT` | Timeout for requests to the MCP server (ms) | 10000 |
| `MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS` | Reset timeout on progress notifications | true |
| `MCP_REQUEST_MAX_TOTAL_TIMEOUT` | Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications) | 60000 |
| `MCP_PROXY_FULL_ADDRESS` | Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577 | "" |
| Setting | Description | Default |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------- |
| `MCP_SERVER_REQUEST_TIMEOUT` | Timeout for requests to the MCP server (ms) | 10000 |
| `MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS` | Reset timeout on progress notifications | true |
| `MCP_REQUEST_MAX_TOTAL_TIMEOUT` | Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications) | 60000 |
| `MCP_PROXY_FULL_ADDRESS` | Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577 | "" |
| `MCP_AUTO_OPEN_ENABLED` | Enable automatic browser opening when inspector starts. Only as environment var, not configurable in browser. | true |
These settings can be adjusted in real-time through the UI and will persist across sessions.
@ -93,6 +181,24 @@ 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:
```
http://localhost:6274/?transport=sse&serverUrl=http://localhost:8787/sse
http://localhost:6274/?transport=streamable-http&serverUrl=http://localhost:8787/mcp
http://localhost:6274/?transport=stdio&serverCommand=npx&serverArgs=arg1%20arg2
```
You can also set initial config settings via query params, for example:
```
http://localhost:6274/?MCP_SERVER_REQUEST_TIMEOUT=10000&MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS=false&MCP_PROXY_FULL_ADDRESS=http://10.1.1.22:5577
```
Note that if both the query param and the corresponding localStorage item are set, the query param will take precedence.
### From this repository
If you're working on the inspector itself:

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-cli",
"version": "0.10.2",
"version": "0.14.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.10.0",
"@modelcontextprotocol/sdk": "^1.12.1",
"commander": "^13.1.0",
"spawn-rx": "^5.1.2"
}

View file

@ -9,10 +9,34 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const distPath = join(__dirname, "../dist");
const server = http.createServer((request, response) => {
return handler(request, response, {
const handlerOptions = {
public: distPath,
rewrites: [{ source: "/**", destination: "/index.html" }],
});
headers: [
{
// Ensure index.html is never cached
source: "index.html",
headers: [
{
key: "Cache-Control",
value: "no-cache, no-store, max-age=0",
},
],
},
{
// Allow long-term caching for hashed assets
source: "assets/**",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable",
},
],
},
],
};
return handler(request, response, handlerOptions);
});
const port = process.env.PORT || 6274;

View file

@ -1,5 +1,6 @@
#!/usr/bin/env node
import open from "open";
import { resolve, dirname } from "path";
import { spawnPromise } from "spawn-rx";
import { fileURLToPath } from "url";
@ -99,6 +100,9 @@ async function main() {
if (serverOk) {
try {
if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") {
open(`http://127.0.0.1:${CLIENT_PORT}`);
}
await spawnPromise("node", [inspectorClientPath], {
env: { ...process.env, PORT: CLIENT_PORT },
signal: abort.signal,

View file

@ -1,6 +1,6 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "jsdom",
testEnvironment: "jest-fixed-jsdom",
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
"\\.css$": "<rootDir>/src/__mocks__/styleMock.js",

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-client",
"version": "0.10.2",
"version": "0.14.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.10.0",
"@modelcontextprotocol/sdk": "^1.12.1",
"@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",

View file

@ -17,6 +17,11 @@ 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, EMPTY_DEBUGGER_STATE } from "./lib/auth-types";
import { OAuthStateMachine } from "./lib/oauth-state-machine";
import { cacheToolOutputSchemas } from "./utils/schemaUtils";
import React, {
Suspense,
useCallback,
@ -28,18 +33,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";
@ -49,9 +57,15 @@ import RootsTab from "./components/RootsTab";
import SamplingTab, { PendingRequest } from "./components/SamplingTab";
import Sidebar from "./components/Sidebar";
import ToolsTab from "./components/ToolsTab";
import { DEFAULT_INSPECTOR_CONFIG } from "./lib/constants";
import { InspectorConfig } from "./lib/configurationTypes";
import { getMCPProxyAddress } from "./utils/configUtils";
import {
getMCPProxyAddress,
getInitialSseUrl,
getInitialTransportType,
getInitialCommand,
getInitialArgs,
initializeInspectorConfig,
} from "./utils/configUtils";
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
@ -71,26 +85,13 @@ const App = () => {
prompts: null,
tools: null,
});
const [command, setCommand] = useState<string>(() => {
return localStorage.getItem("lastCommand") || "mcp-server-everything";
});
const [args, setArgs] = useState<string>(() => {
return localStorage.getItem("lastArgs") || "";
});
const [command, setCommand] = useState<string>(getInitialCommand);
const [args, setArgs] = useState<string>(getInitialArgs);
const [sseUrl, setSseUrl] = useState<string>(() => {
return localStorage.getItem("lastSseUrl") || "http://localhost:3001/sse";
});
const [sseUrl, setSseUrl] = useState<string>(getInitialSseUrl);
const [transportType, setTransportType] = useState<
"stdio" | "sse" | "streamable-http"
>(() => {
return (
(localStorage.getItem("lastTransportType") as
| "stdio"
| "sse"
| "streamable-http") || "stdio"
);
});
>(getInitialTransportType);
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
const [stdErrNotifications, setStdErrNotifications] = useState<
@ -99,27 +100,9 @@ const App = () => {
const [roots, setRoots] = useState<Root[]>([]);
const [env, setEnv] = useState<Record<string, string>>({});
const [config, setConfig] = useState<InspectorConfig>(() => {
const savedConfig = localStorage.getItem(CONFIG_LOCAL_STORAGE_KEY);
if (savedConfig) {
// merge default config with saved config
const mergedConfig = {
...DEFAULT_INSPECTOR_CONFIG,
...JSON.parse(savedConfig),
} as InspectorConfig;
// update description of keys to match the new description (in case of any updates to the default config description)
Object.entries(mergedConfig).forEach(([key, value]) => {
mergedConfig[key as keyof InspectorConfig] = {
...value,
label: DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].label,
};
});
return mergedConfig;
}
return DEFAULT_INSPECTOR_CONFIG;
});
const [config, setConfig] = useState<InspectorConfig>(() =>
initializeInspectorConfig(CONFIG_LOCAL_STORAGE_KEY),
);
const [bearerToken, setBearerToken] = useState<string>(() => {
return localStorage.getItem("lastBearerToken") || "";
});
@ -136,6 +119,16 @@ const App = () => {
}
>
>([]);
const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false);
// Auth debugger state
const [authState, setAuthState] =
useState<AuthDebuggerState>(EMPTY_DEBUGGER_STATE);
// 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[]>([]);
@ -232,12 +225,116 @@ 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(
async ({
authorizationCode,
errorMsg,
restoredState,
}: {
authorizationCode?: string;
errorMsg?: string;
restoredState?: AuthDebuggerState;
}) => {
setIsAuthDebuggerVisible(true);
if (errorMsg) {
updateAuthState({
latestError: new Error(errorMsg),
});
return;
}
if (restoredState && authorizationCode) {
// Restore the previous auth state and continue the OAuth flow
let currentState: AuthDebuggerState = {
...restoredState,
authorizationCode,
oauthStep: "token_request",
isInitiatingAuth: true,
statusMessage: null,
latestError: null,
};
try {
// Create a new state machine instance to continue the flow
const stateMachine = new OAuthStateMachine(sseUrl, (updates) => {
currentState = { ...currentState, ...updates };
});
// Continue stepping through the OAuth flow from where we left off
while (
currentState.oauthStep !== "complete" &&
currentState.oauthStep !== "authorization_code"
) {
await stateMachine.executeStep(currentState);
}
if (currentState.oauthStep === "complete") {
// After the flow completes or reaches a user-input step, update the app state
updateAuthState({
...currentState,
statusMessage: {
type: "success",
message: "Authentication completed successfully",
},
isInitiatingAuth: false,
});
}
} catch (error) {
console.error("OAuth continuation error:", error);
updateAuthState({
latestError:
error instanceof Error ? error : new Error(String(error)),
statusMessage: {
type: "error",
message: `Failed to complete OAuth flow: ${error instanceof Error ? error.message : String(error)}`,
},
isInitiatingAuth: false,
});
}
} else if (authorizationCode) {
// Fallback to the original behavior if no state was restored
updateAuthState({
authorizationCode,
oauthStep: "token_request",
});
}
},
[sseUrl],
);
// 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);
}
};
loadOAuthTokens();
}, [sseUrl]);
useEffect(() => {
fetch(`${getMCPProxyAddress(config)}/config`)
.then((response) => response.json())
@ -419,6 +516,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>) => {
@ -471,6 +570,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"),
@ -482,6 +594,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
@ -530,7 +653,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}
@ -569,17 +692,34 @@ 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">
{!serverCapabilities?.resources &&
!serverCapabilities?.prompts &&
!serverCapabilities?.tools ? (
<div className="flex items-center justify-center p-4">
<p className="text-lg text-gray-500">
The connected server does not support any MCP capabilities
</p>
</div>
<>
<div className="flex items-center justify-center p-4">
<p className="text-lg text-gray-500 dark:text-gray-400">
The connected server does not support any MCP
capabilities
</p>
</div>
<PingTab
onPingClick={() => {
void sendMCPRequest(
{
method: "ping" as const,
},
EmptyResultSchema,
);
}}
/>
</>
) : (
<>
<ResourcesTab
@ -647,6 +787,7 @@ const App = () => {
setSelectedPrompt={(prompt) => {
clearError("prompts");
setSelectedPrompt(prompt);
setPromptContent("");
}}
handleCompletion={handleCompletion}
completionsSupported={completionsSupported}
@ -663,6 +804,8 @@ const App = () => {
clearTools={() => {
setTools([]);
setNextToolCursor(undefined);
// Clear cached output schemas
cacheToolOutputSchemas([]);
}}
callTool={async (name, params) => {
clearError("tools");
@ -700,15 +843,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>
@ -719,7 +883,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" />

View file

@ -0,0 +1,305 @@
import { useCallback, useMemo, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { DebugInspectorOAuthClientProvider } from "../lib/auth";
import { AlertCircle } from "lucide-react";
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "../lib/auth-types";
import { OAuthFlowProgress } from "./OAuthFlowProgress";
import { OAuthStateMachine } from "../lib/oauth-state-machine";
import { SESSION_KEYS } from "../lib/constants";
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) => {
// Check for existing tokens on mount
useEffect(() => {
if (serverUrl && !authState.oauthTokens) {
const checkTokens = async () => {
try {
const provider = new DebugInspectorOAuthClientProvider(serverUrl);
const existingTokens = await provider.tokens();
if (existingTokens) {
updateAuthState({
oauthTokens: existingTokens,
oauthStep: "complete",
});
}
} catch (error) {
console.error("Failed to load existing OAuth tokens:", error);
}
};
checkTokens();
}
}, [serverUrl, updateAuthState, authState.oauthTokens]);
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
) {
// Store the current auth state before redirecting
sessionStorage.setItem(
SESSION_KEYS.AUTH_DEBUGGER_STATE,
JSON.stringify(currentState),
);
// 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({
...EMPTY_DEBUGGER_STATE,
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} />
)}
<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;

View file

@ -250,7 +250,12 @@ const DynamicJsonForm = ({
<div className="space-y-4">
<div className="flex justify-end space-x-2">
{isJsonMode && (
<Button variant="outline" size="sm" onClick={formatJson}>
<Button
type="button"
variant="outline"
size="sm"
onClick={formatJson}
>
Format JSON
</Button>
)}

View file

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

View file

@ -3,7 +3,7 @@ import type { JsonValue } from "@/utils/jsonUtils";
import clsx from "clsx";
import { Copy, CheckCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { useToast } from "@/lib/hooks/useToast";
import { getDataType, tryParseJson } from "@/utils/jsonUtils";
interface JsonViewProps {

View file

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

View file

@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import { InspectorOAuthClientProvider } from "../lib/auth";
import { SESSION_KEYS } from "../lib/constants";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { useToast } from "@/hooks/use-toast.ts";
import { useToast } from "@/lib/hooks/useToast";
import {
generateOAuthErrorDescription,
parseOAuthCallbackParams,

View file

@ -0,0 +1,110 @@
import { useEffect } from "react";
import { SESSION_KEYS } from "../lib/constants";
import {
generateOAuthErrorDescription,
parseOAuthCallbackParams,
} from "@/utils/oauthUtils.ts";
import { AuthDebuggerState } from "@/lib/auth-types";
interface OAuthCallbackProps {
onConnect: ({
authorizationCode,
errorMsg,
restoredState,
}: {
authorizationCode?: string;
errorMsg?: string;
restoredState?: AuthDebuggerState;
}) => 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);
// Try to restore the auth state
const storedState = sessionStorage.getItem(
SESSION_KEYS.AUTH_DEBUGGER_STATE,
);
let restoredState = null;
if (storedState) {
try {
restoredState = JSON.parse(storedState);
// Clean up the stored state
sessionStorage.removeItem(SESSION_KEYS.AUTH_DEBUGGER_STATE);
} catch (e) {
console.error("Failed to parse stored auth state:", e);
}
}
// 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, along with restored state
onConnect({ authorizationCode: params.code, restoredState });
};
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;

View file

@ -0,0 +1,365 @@
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")}
>
{authState.oauthMetadata && (
<details className="text-xs mt-2">
<summary className="cursor-pointer text-muted-foreground font-medium">
OAuth Metadata Sources
{!authState.resourceMetadata && " ℹ️"}
</summary>
{authState.resourceMetadata && (
<div className="mt-2">
<p className="font-medium">Resource Metadata:</p>
<p className="text-xs text-muted-foreground">
From{" "}
{
new URL(
"/.well-known/oauth-protected-resource",
serverUrl,
).href
}
</p>
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
{JSON.stringify(authState.resourceMetadata, null, 2)}
</pre>
</div>
)}
{authState.resourceMetadataError && (
<div className="mt-2 p-3 border border-blue-300 bg-blue-50 rounded-md">
<p className="text-sm font-medium text-blue-700">
ℹ️ No resource metadata available from{" "}
<a
href={
new URL(
"/.well-known/oauth-protected-resource",
serverUrl,
).href
}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-700"
>
{
new URL(
"/.well-known/oauth-protected-resource",
serverUrl,
).href
}
</a>
</p>
<p className="text-xs text-blue-600 mt-1">
Resource metadata was added in the{" "}
<a href="https://modelcontextprotocol.io/specification/draft/basic/authorization#2-3-1-authorization-server-location">
2025-DRAFT-v2 specification update
</a>
<br />
{authState.resourceMetadataError.message}
{authState.resourceMetadataError instanceof TypeError &&
" (This could indicate the endpoint doesn't exist or does not have CORS configured)"}
</p>
</div>
)}
{authState.oauthMetadata && (
<div className="mt-2">
<p className="font-medium">Authorization Server Metadata:</p>
{authState.authServerUrl && (
<p className="text-xs text-muted-foreground">
From{" "}
{
new URL(
"/.well-known/oauth-authorization-server",
authState.authServerUrl,
).href
}
</p>
)}
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
{JSON.stringify(authState.oauthMetadata, null, 2)}
</pre>
</div>
)}
</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>
);
};

View file

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

View file

@ -16,6 +16,7 @@ import ListPane from "./ListPane";
import { useEffect, useState } from "react";
import { useCompletionState } from "@/lib/hooks/useCompletionState";
import JsonView from "./JsonView";
import { UriTemplate } from "@modelcontextprotocol/sdk/shared/uriTemplate.js";
const ResourcesTab = ({
resources,
@ -79,10 +80,7 @@ const ResourcesTab = ({
template: string,
values: Record<string, string>,
): string => {
return template.replace(
/{([^}]+)}/g,
(_, key) => values[key] || `{${key}}`,
);
return new UriTemplate(template).expand(values);
};
const handleTemplateValueChange = async (key: string, value: string) => {
@ -173,8 +171,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,31 +232,30 @@ 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
.match(/{([^}]+)}/g)
?.map((param) => {
const key = param.slice(1, -1);
return (
<div key={key}>
<Label htmlFor={key}>{key}</Label>
<Combobox
id={key}
placeholder={`Enter ${key}`}
value={templateValues[key] || ""}
onChange={(value) =>
handleTemplateValueChange(key, value)
}
onInputChange={(value) =>
handleTemplateValueChange(key, value)
}
options={completions[key] || []}
/>
</div>
);
})}
{new UriTemplate(
selectedTemplate.uriTemplate,
).variableNames?.map((key) => {
return (
<div key={key}>
<Label htmlFor={key}>{key}</Label>
<Combobox
id={key}
placeholder={`Enter ${key}`}
value={templateValues[key] || ""}
onChange={(value) =>
handleTemplateValueChange(key, value)
}
onInputChange={(value) =>
handleTemplateValueChange(key, value)
}
options={completions[key] || []}
/>
</div>
);
})}
<Button
onClick={handleReadTemplateResource}
disabled={Object.keys(templateValues).length === 0}

View file

@ -0,0 +1,167 @@
import { Button } from "@/components/ui/button";
import JsonView from "./JsonView";
import { useMemo, useState } from "react";
import {
CreateMessageResult,
CreateMessageResultSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { PendingRequest } from "./SamplingTab";
import DynamicJsonForm from "./DynamicJsonForm";
import { useToast } from "@/lib/hooks/useToast";
import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils";
export type SamplingRequestProps = {
request: PendingRequest;
onApprove: (id: number, result: CreateMessageResult) => void;
onReject: (id: number) => void;
};
const SamplingRequest = ({
onApprove,
request,
onReject,
}: SamplingRequestProps) => {
const { toast } = useToast();
const [messageResult, setMessageResult] = useState<JsonValue>({
model: "stub-model",
stopReason: "endTurn",
role: "assistant",
content: {
type: "text",
text: "",
},
});
const contentType = (
(messageResult as { [key: string]: JsonValue })?.content as {
[key: string]: JsonValue;
}
)?.type;
const schema = useMemo(() => {
const s: JsonSchemaType = {
type: "object",
description: "Message result",
properties: {
model: {
type: "string",
default: "stub-model",
description: "model name",
},
stopReason: {
type: "string",
default: "endTurn",
description: "Stop reason",
},
role: {
type: "string",
default: "endTurn",
description: "Role of the model",
},
content: {
type: "object",
properties: {
type: {
type: "string",
default: "text",
description: "Type of content",
},
},
},
},
};
if (contentType === "text" && s.properties) {
s.properties.content.properties = {
...s.properties.content.properties,
text: {
type: "string",
default: "",
description: "text content",
},
};
setMessageResult((prev) => ({
...(prev as { [key: string]: JsonValue }),
content: {
type: contentType,
text: "",
},
}));
} else if (contentType === "image" && s.properties) {
s.properties.content.properties = {
...s.properties.content.properties,
data: {
type: "string",
default: "",
description: "Base64 encoded image data",
},
mimeType: {
type: "string",
default: "",
description: "Mime type of the image",
},
};
setMessageResult((prev) => ({
...(prev as { [key: string]: JsonValue }),
content: {
type: contentType,
data: "",
mimeType: "",
},
}));
}
return s;
}, [contentType]);
const handleApprove = (id: number) => {
const validationResult = CreateMessageResultSchema.safeParse(messageResult);
if (!validationResult.success) {
toast({
title: "Error",
description: `There was an error validating the message result: ${validationResult.error.message}`,
variant: "destructive",
});
return;
}
onApprove(id, validationResult.data);
};
return (
<div
data-testid="sampling-request"
className="flex gap-4 p-4 border rounded-lg space-y-4"
>
<div className="flex-1 bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
<JsonView data={JSON.stringify(request.request)} />
</div>
<form className="flex-1 space-y-4">
<div className="space-y-2">
<DynamicJsonForm
schema={schema}
value={messageResult}
onChange={(newValue: JsonValue) => {
setMessageResult(newValue);
}}
/>
</div>
<div className="flex space-x-2 mt-1">
<Button type="button" onClick={() => handleApprove(request.id)}>
Approve
</Button>
<Button
type="button"
variant="outline"
onClick={() => onReject(request.id)}
>
Reject
</Button>
</div>
</form>
</div>
);
};
export default SamplingRequest;

View file

@ -1,11 +1,10 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { TabsContent } from "@/components/ui/tabs";
import {
CreateMessageRequest,
CreateMessageResult,
} from "@modelcontextprotocol/sdk/types.js";
import JsonView from "./JsonView";
import SamplingRequest from "./SamplingRequest";
export type PendingRequest = {
id: number;
@ -19,19 +18,6 @@ export type Props = {
};
const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
const handleApprove = (id: number) => {
// For now, just return a stub response
onApprove(id, {
model: "stub-model",
stopReason: "endTurn",
role: "assistant",
content: {
type: "text",
text: "This is a stub response.",
},
});
};
return (
<TabsContent value="sampling">
<div className="h-96">
@ -44,21 +30,12 @@ const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
<div className="mt-4 space-y-4">
<h3 className="text-lg font-semibold">Recent Requests</h3>
{pendingRequests.map((request) => (
<div key={request.id} className="p-4 border rounded-lg space-y-4">
<JsonView
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 rounded"
data={JSON.stringify(request.request)}
/>
<div className="flex space-x-2">
<Button onClick={() => handleApprove(request.id)}>
Approve
</Button>
<Button variant="outline" onClick={() => onReject(request.id)}>
Reject
</Button>
</div>
</div>
<SamplingRequest
key={request.id}
request={request}
onApprove={onApprove}
onReject={onReject}
/>
))}
{pendingRequests.length === 0 && (
<p className="text-gray-500">No pending requests</p>

Some files were not shown because too many files have changed in this diff Show more