Merge branch 'main' into bugfix/issue-114

This commit is contained in:
Jack Steam
2024-12-23 14:37:54 -07:00
committed by GitHub
4 changed files with 304 additions and 222 deletions

View File

@@ -1,37 +1,26 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { useDraggablePane } from "./lib/hooks/useDraggablePane";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { useConnection } from "./lib/hooks/useConnection";
import { import {
ClientNotification,
ClientRequest, ClientRequest,
CompatibilityCallToolResult, CompatibilityCallToolResult,
CompatibilityCallToolResultSchema, CompatibilityCallToolResultSchema,
CreateMessageRequestSchema,
CreateMessageResult, CreateMessageResult,
EmptyResultSchema, EmptyResultSchema,
GetPromptResultSchema, GetPromptResultSchema,
ListPromptsResultSchema, ListPromptsResultSchema,
ListResourcesResultSchema, ListResourcesResultSchema,
ListResourceTemplatesResultSchema, ListResourceTemplatesResultSchema,
ListRootsRequestSchema,
ListToolsResultSchema,
ProgressNotificationSchema,
ReadResourceResultSchema, ReadResourceResultSchema,
Request, ListToolsResultSchema,
Resource, Resource,
ResourceTemplate, ResourceTemplate,
Root, Root,
ServerNotification, ServerNotification,
Tool, Tool
ServerCapabilitiesSchema,
Result,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import { useCallback, useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import { StdErrNotification } from "./lib/notificationTypes";
Notification,
StdErrNotification,
StdErrNotificationSchema,
} from "./lib/notificationTypes";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { import {
@@ -43,8 +32,7 @@ import {
MessageSquare, MessageSquare,
} from "lucide-react"; } from "lucide-react";
import { toast } from "react-toastify"; import { z } from "zod";
import { z, type ZodType } from "zod";
import "./App.css"; import "./App.css";
import ConsoleTab from "./components/ConsoleTab"; import ConsoleTab from "./components/ConsoleTab";
import HistoryAndNotifications from "./components/History"; import HistoryAndNotifications from "./components/History";
@@ -56,21 +44,11 @@ import SamplingTab, { PendingRequest } from "./components/SamplingTab";
import Sidebar from "./components/Sidebar"; import Sidebar from "./components/Sidebar";
import ToolsTab from "./components/ToolsTab"; import ToolsTab from "./components/ToolsTab";
type ServerCapabilities = z.infer<typeof ServerCapabilitiesSchema>;
const DEFAULT_REQUEST_TIMEOUT_MSEC = 10000;
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const PROXY_PORT = params.get("proxyPort") ?? "3000"; const PROXY_PORT = params.get("proxyPort") ?? "3000";
const REQUEST_TIMEOUT =
parseInt(params.get("timeout") ?? "") || DEFAULT_REQUEST_TIMEOUT_MSEC;
const PROXY_SERVER_URL = `http://localhost:${PROXY_PORT}`; const PROXY_SERVER_URL = `http://localhost:${PROXY_PORT}`;
const App = () => { const App = () => {
const [connectionStatus, setConnectionStatus] = useState<
"disconnected" | "connected" | "error"
>("disconnected");
const [serverCapabilities, setServerCapabilities] = useState<ServerCapabilities | null>(null);
const [resources, setResources] = useState<Resource[]>([]); const [resources, setResources] = useState<Resource[]>([]);
const [resourceTemplates, setResourceTemplates] = useState< const [resourceTemplates, setResourceTemplates] = useState<
ResourceTemplate[] ResourceTemplate[]
@@ -95,10 +73,6 @@ const App = () => {
const [sseUrl, setSseUrl] = useState<string>("http://localhost:3001/sse"); const [sseUrl, setSseUrl] = useState<string>("http://localhost:3001/sse");
const [transportType, setTransportType] = useState<"stdio" | "sse">("stdio"); const [transportType, setTransportType] = useState<"stdio" | "sse">("stdio");
const [requestHistory, setRequestHistory] = useState<
{ request: string; response?: string }[]
>([]);
const [mcpClient, setMcpClient] = useState<Client | null>(null);
const [notifications, setNotifications] = useState<ServerNotification[]>([]); const [notifications, setNotifications] = useState<ServerNotification[]>([]);
const [stdErrNotifications, setStdErrNotifications] = useState< const [stdErrNotifications, setStdErrNotifications] = useState<
StdErrNotification[] StdErrNotification[]
@@ -149,49 +123,64 @@ const App = () => {
>(); >();
const [nextToolCursor, setNextToolCursor] = useState<string | undefined>(); const [nextToolCursor, setNextToolCursor] = useState<string | undefined>();
const progressTokenRef = useRef(0); const progressTokenRef = useRef(0);
const [historyPaneHeight, setHistoryPaneHeight] = useState<number>(300);
const [isDragging, setIsDragging] = useState(false);
const dragStartY = useRef<number>(0);
const dragStartHeight = useRef<number>(0);
const handleDragStart = useCallback( const {
(e: React.MouseEvent) => { height: historyPaneHeight,
setIsDragging(true); handleDragStart
dragStartY.current = e.clientY; } = useDraggablePane(300);
dragStartHeight.current = historyPaneHeight;
document.body.style.userSelect = "none"; const {
connectionStatus,
serverCapabilities,
mcpClient,
requestHistory,
makeRequest: makeConnectionRequest,
sendNotification,
connect: connectMcpServer
} = useConnection({
transportType,
command,
args,
sseUrl,
env,
proxyServerUrl: PROXY_SERVER_URL,
onNotification: (notification) => {
setNotifications(prev => [...prev, notification as ServerNotification]);
}, },
[historyPaneHeight], onStdErrNotification: (notification) => {
); setStdErrNotifications(prev => [...prev, notification as StdErrNotification]);
const handleDragMove = useCallback(
(e: MouseEvent) => {
if (!isDragging) return;
const deltaY = dragStartY.current - e.clientY;
const newHeight = Math.max(
100,
Math.min(800, dragStartHeight.current + deltaY),
);
setHistoryPaneHeight(newHeight);
}, },
[isDragging], onPendingRequest: (request, resolve, reject) => {
); setPendingSampleRequests(prev => [
...prev,
{ id: nextRequestId.current++, request, resolve, reject }
]);
},
getRoots: () => rootsRef.current
});
const handleDragEnd = useCallback(() => { const makeRequest = async <T extends z.ZodType>(
setIsDragging(false); request: ClientRequest,
document.body.style.userSelect = ""; schema: T,
}, []); tabKey?: keyof typeof errors,
) => {
useEffect(() => { try {
if (isDragging) { const response = await makeConnectionRequest(request, schema);
window.addEventListener("mousemove", handleDragMove); if (tabKey !== undefined) {
window.addEventListener("mouseup", handleDragEnd); clearError(tabKey);
return () => {
window.removeEventListener("mousemove", handleDragMove);
window.removeEventListener("mouseup", handleDragEnd);
};
} }
}, [isDragging, handleDragMove, handleDragEnd]); return response;
} catch (e) {
const errorString = (e as Error).message ?? String(e);
if (tabKey !== undefined) {
setErrors((prev) => ({
...prev,
[tabKey]: errorString,
}));
}
throw e;
}
};
useEffect(() => { useEffect(() => {
localStorage.setItem("lastCommand", command); localStorage.setItem("lastCommand", command);
@@ -228,83 +217,10 @@ const App = () => {
} }
}, []); }, []);
const pushHistory = (request: object, response?: object) => {
setRequestHistory((prev) => [
...prev,
{
request: JSON.stringify(request),
response: response !== undefined ? JSON.stringify(response) : undefined,
},
]);
};
const clearError = (tabKey: keyof typeof errors) => { const clearError = (tabKey: keyof typeof errors) => {
setErrors((prev) => ({ ...prev, [tabKey]: null })); setErrors((prev) => ({ ...prev, [tabKey]: null }));
}; };
const makeRequest = async <T extends ZodType<object>>(
request: ClientRequest,
schema: T,
tabKey?: keyof typeof errors,
) => {
if (!mcpClient) {
throw new Error("MCP client not connected");
}
try {
const abortController = new AbortController();
const timeoutId = setTimeout(() => {
abortController.abort("Request timed out");
}, REQUEST_TIMEOUT);
let response;
try {
response = await mcpClient.request(request, schema, {
signal: abortController.signal,
});
pushHistory(request, response);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
pushHistory(request, { error: errorMessage });
throw error;
} finally {
clearTimeout(timeoutId);
}
if (tabKey !== undefined) {
clearError(tabKey);
}
return response;
} catch (e: unknown) {
const errorString = (e as Error).message ?? String(e);
if (tabKey === undefined) {
toast.error(errorString);
} else {
setErrors((prev) => ({
...prev,
[tabKey]: errorString,
}));
}
throw e;
}
};
const sendNotification = async (notification: ClientNotification) => {
if (!mcpClient) {
throw new Error("MCP client not connected");
}
try {
await mcpClient.notification(notification);
pushHistory(notification);
} catch (e: unknown) {
toast.error((e as Error).message ?? String(e));
throw e;
}
};
const listResources = async () => { const listResources = async () => {
const response = await makeRequest( const response = await makeRequest(
{ {
@@ -407,82 +323,6 @@ const App = () => {
await sendNotification({ method: "notifications/roots/list_changed" }); await sendNotification({ method: "notifications/roots/list_changed" });
}; };
const connectMcpServer = async () => {
try {
const client = new Client<Request, Notification, Result>(
{
name: "mcp-inspector",
version: "0.0.1",
},
{
capabilities: {
// Support all client capabilities since we're an inspector tool
sampling: {},
roots: {
listChanged: true,
},
},
},
);
const backendUrl = new URL(`${PROXY_SERVER_URL}/sse`);
backendUrl.searchParams.append("transportType", transportType);
if (transportType === "stdio") {
backendUrl.searchParams.append("command", command);
backendUrl.searchParams.append("args", args);
backendUrl.searchParams.append("env", JSON.stringify(env));
} else {
backendUrl.searchParams.append("url", sseUrl);
}
const clientTransport = new SSEClientTransport(backendUrl);
client.setNotificationHandler(
ProgressNotificationSchema,
(notification) => {
setNotifications((prevNotifications) => [
...prevNotifications,
notification,
]);
},
);
client.setNotificationHandler(
StdErrNotificationSchema,
(notification) => {
setStdErrNotifications((prevErrorNotifications) => [
...prevErrorNotifications,
notification,
]);
},
);
await client.connect(clientTransport);
const capabilities = client.getServerCapabilities();
setServerCapabilities(capabilities ?? null);
client.setRequestHandler(CreateMessageRequestSchema, (request) => {
return new Promise<CreateMessageResult>((resolve, reject) => {
setPendingSampleRequests((prev) => [
...prev,
{ id: nextRequestId.current++, request, resolve, reject },
]);
});
});
client.setRequestHandler(ListRootsRequestSchema, async () => {
return { roots: rootsRef.current };
});
setMcpClient(client);
setConnectionStatus("connected");
} catch (e) {
console.error(e);
setConnectionStatus("error");
}
};
return ( return (
<div className="flex h-screen bg-background"> <div className="flex h-screen bg-background">
<Sidebar <Sidebar

View File

@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { Play, ChevronDown, ChevronRight, CircleHelp, Bug } from "lucide-react"; import { Play, ChevronDown, ChevronRight, CircleHelp, Bug, Github } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
@@ -86,6 +86,7 @@ const Sidebar = ({
placeholder="Command" placeholder="Command"
value={command} value={command}
onChange={(e) => setCommand(e.target.value)} onChange={(e) => setCommand(e.target.value)}
className="font-mono"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -94,6 +95,7 @@ const Sidebar = ({
placeholder="Arguments (space-separated)" placeholder="Arguments (space-separated)"
value={args} value={args}
onChange={(e) => setArgs(e.target.value)} onChange={(e) => setArgs(e.target.value)}
className="font-mono"
/> />
</div> </div>
</> </>
@@ -104,6 +106,7 @@ const Sidebar = ({
placeholder="URL" placeholder="URL"
value={sseUrl} value={sseUrl}
onChange={(e) => setSseUrl(e.target.value)} onChange={(e) => setSseUrl(e.target.value)}
className="font-mono"
/> />
</div> </div>
)} )}
@@ -135,6 +138,7 @@ const Sidebar = ({
newEnv[e.target.value] = value; newEnv[e.target.value] = value;
setEnv(newEnv); setEnv(newEnv);
}} }}
className="font-mono"
/> />
<Input <Input
placeholder="Value" placeholder="Value"
@@ -144,6 +148,7 @@ const Sidebar = ({
newEnv[key] = e.target.value; newEnv[key] = e.target.value;
setEnv(newEnv); setEnv(newEnv);
}} }}
className="font-mono"
/> />
</div> </div>
<Button <Button
@@ -227,7 +232,7 @@ const Sidebar = ({
setTheme(value as "system" | "light" | "dark") setTheme(value as "system" | "light" | "dark")
} }
> >
<SelectTrigger className="w-[120px]" id="theme-select"> <SelectTrigger className="w-[100px]" id="theme-select">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -248,6 +253,11 @@ const Sidebar = ({
<Bug className="w-4 h-4 text-gray-800" /> <Bug className="w-4 h-4 text-gray-800" />
</Button> </Button>
</a> </a>
<a href="https://github.com/modelcontextprotocol/inspector" target="_blank" rel="noopener noreferrer">
<Button variant="ghost" title="Report bugs or contribute on GitHub">
<Github className="w-4 h-4 text-gray-800" />
</Button>
</a>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,188 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import {
ClientNotification,
ClientRequest,
CreateMessageRequestSchema,
ListRootsRequestSchema,
ProgressNotificationSchema,
Request,
Result,
ServerCapabilities,
} from "@modelcontextprotocol/sdk/types.js";
import { useState } from "react";
import { toast } from "react-toastify";
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
import { z } from "zod";
const DEFAULT_REQUEST_TIMEOUT_MSEC = 10000;
interface UseConnectionOptions {
transportType: "stdio" | "sse";
command: string;
args: string;
sseUrl: string;
env: Record<string, string>;
proxyServerUrl: string;
requestTimeout?: number;
onNotification?: (notification: Notification) => void;
onStdErrNotification?: (notification: Notification) => void;
onPendingRequest?: (request: any, resolve: any, reject: any) => void;
getRoots?: () => any[];
}
export function useConnection({
transportType,
command,
args,
sseUrl,
env,
proxyServerUrl,
requestTimeout = DEFAULT_REQUEST_TIMEOUT_MSEC,
onNotification,
onStdErrNotification,
onPendingRequest,
getRoots,
}: UseConnectionOptions) {
const [connectionStatus, setConnectionStatus] = useState<"disconnected" | "connected" | "error">("disconnected");
const [serverCapabilities, setServerCapabilities] = useState<ServerCapabilities | null>(null);
const [mcpClient, setMcpClient] = useState<Client | null>(null);
const [requestHistory, setRequestHistory] = useState<{ request: string; response?: string }[]>([]);
const pushHistory = (request: object, response?: object) => {
setRequestHistory((prev) => [
...prev,
{
request: JSON.stringify(request),
response: response !== undefined ? JSON.stringify(response) : undefined,
},
]);
};
const makeRequest = async <T extends z.ZodType>(
request: ClientRequest,
schema: T
) => {
if (!mcpClient) {
throw new Error("MCP client not connected");
}
try {
const abortController = new AbortController();
const timeoutId = setTimeout(() => {
abortController.abort("Request timed out");
}, requestTimeout);
let response;
try {
response = await mcpClient.request(request, schema, {
signal: abortController.signal,
});
pushHistory(request, response);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
pushHistory(request, { error: errorMessage });
throw error;
} finally {
clearTimeout(timeoutId);
}
return response;
} catch (e: unknown) {
const errorString = (e as Error).message ?? String(e);
toast.error(errorString);
throw e;
}
};
const sendNotification = async (notification: ClientNotification) => {
if (!mcpClient) {
throw new Error("MCP client not connected");
}
try {
await mcpClient.notification(notification);
pushHistory(notification);
} catch (e: unknown) {
toast.error((e as Error).message ?? String(e));
throw e;
}
};
const connect = async () => {
try {
const client = new Client<Request, Notification, Result>(
{
name: "mcp-inspector",
version: "0.0.1",
},
{
capabilities: {
sampling: {},
roots: {
listChanged: true,
},
},
},
);
const backendUrl = new URL(`${proxyServerUrl}/sse`);
backendUrl.searchParams.append("transportType", transportType);
if (transportType === "stdio") {
backendUrl.searchParams.append("command", command);
backendUrl.searchParams.append("args", args);
backendUrl.searchParams.append("env", JSON.stringify(env));
} else {
backendUrl.searchParams.append("url", sseUrl);
}
const clientTransport = new SSEClientTransport(backendUrl);
if (onNotification) {
client.setNotificationHandler(ProgressNotificationSchema, onNotification);
}
if (onStdErrNotification) {
client.setNotificationHandler(StdErrNotificationSchema, onStdErrNotification);
}
await client.connect(clientTransport);
const capabilities = client.getServerCapabilities();
setServerCapabilities(capabilities ?? null);
if (onPendingRequest) {
client.setRequestHandler(CreateMessageRequestSchema, (request) => {
return new Promise((resolve, reject) => {
onPendingRequest(request, resolve, reject);
});
});
}
if (getRoots) {
client.setRequestHandler(ListRootsRequestSchema, async () => {
return { roots: getRoots() };
});
}
setMcpClient(client);
setConnectionStatus("connected");
} catch (e) {
console.error(e);
setConnectionStatus("error");
}
};
return {
connectionStatus,
serverCapabilities,
mcpClient,
requestHistory,
makeRequest,
sendNotification,
connect
};
}

View File

@@ -0,0 +1,44 @@
import { useCallback, useEffect, useRef, useState } from "react";
export function useDraggablePane(initialHeight: number) {
const [height, setHeight] = useState(initialHeight);
const [isDragging, setIsDragging] = useState(false);
const dragStartY = useRef<number>(0);
const dragStartHeight = useRef<number>(0);
const handleDragStart = useCallback((e: React.MouseEvent) => {
setIsDragging(true);
dragStartY.current = e.clientY;
dragStartHeight.current = height;
document.body.style.userSelect = "none";
}, [height]);
const handleDragMove = useCallback((e: MouseEvent) => {
if (!isDragging) return;
const deltaY = dragStartY.current - e.clientY;
const newHeight = Math.max(100, Math.min(800, dragStartHeight.current + deltaY));
setHeight(newHeight);
}, [isDragging]);
const handleDragEnd = useCallback(() => {
setIsDragging(false);
document.body.style.userSelect = "";
}, []);
useEffect(() => {
if (isDragging) {
window.addEventListener("mousemove", handleDragMove);
window.addEventListener("mouseup", handleDragEnd);
return () => {
window.removeEventListener("mousemove", handleDragMove);
window.removeEventListener("mouseup", handleDragEnd);
};
}
}, [isDragging, handleDragMove, handleDragEnd]);
return {
height,
isDragging,
handleDragStart
};
}