Merge remote-tracking branch 'theirs/main' into max/default-tool-json

This commit is contained in:
Maxwell Gerber
2025-04-08 15:17:11 -07:00
16 changed files with 884 additions and 498 deletions

View File

@@ -48,12 +48,16 @@ The MCP Inspector includes a proxy server that can run and communicate with loca
### Configuration ### Configuration
The MCP Inspector supports the following configuration settings. To change them click on the `Configuration` button in the MCP Inspector UI : The MCP Inspector supports the following configuration settings. To change them, click on the `Configuration` button in the MCP Inspector UI:
| Name | Purpose | Default Value | | Setting | Description | Default |
| -------------------------- | ----------------------------------------------------------------------------------------- | ------------- | | --------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------- |
| MCP_SERVER_REQUEST_TIMEOUT | Maximum time in milliseconds to wait for a response from the MCP server before timing out | 10000 | | `MCP_SERVER_REQUEST_TIMEOUT` | Timeout for requests to the MCP server (ms) | 10000 |
| MCP_PROXY_FULL_ADDRESS | The full URL of the MCP Inspector proxy server (e.g. `http://10.2.1.14:2277`) | `null` | | `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 | "" |
These settings can be adjusted in real-time through the UI and will persist across sessions.
### From this repository ### From this repository

View File

@@ -45,10 +45,7 @@ import Sidebar from "./components/Sidebar";
import ToolsTab from "./components/ToolsTab"; import ToolsTab from "./components/ToolsTab";
import { DEFAULT_INSPECTOR_CONFIG } from "./lib/constants"; import { DEFAULT_INSPECTOR_CONFIG } from "./lib/constants";
import { InspectorConfig } from "./lib/configurationTypes"; import { InspectorConfig } from "./lib/configurationTypes";
import { import { getMCPProxyAddress } from "./utils/configUtils";
getMCPProxyAddress,
getMCPServerRequestTimeout,
} from "./utils/configUtils";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
@@ -98,10 +95,21 @@ const App = () => {
const [config, setConfig] = useState<InspectorConfig>(() => { const [config, setConfig] = useState<InspectorConfig>(() => {
const savedConfig = localStorage.getItem(CONFIG_LOCAL_STORAGE_KEY); const savedConfig = localStorage.getItem(CONFIG_LOCAL_STORAGE_KEY);
if (savedConfig) { if (savedConfig) {
return { // merge default config with saved config
const mergedConfig = {
...DEFAULT_INSPECTOR_CONFIG, ...DEFAULT_INSPECTOR_CONFIG,
...JSON.parse(savedConfig), ...JSON.parse(savedConfig),
} as InspectorConfig; } 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; return DEFAULT_INSPECTOR_CONFIG;
}); });
@@ -148,7 +156,7 @@ const App = () => {
serverCapabilities, serverCapabilities,
mcpClient, mcpClient,
requestHistory, requestHistory,
makeRequest: makeConnectionRequest, makeRequest,
sendNotification, sendNotification,
handleCompletion, handleCompletion,
completionsSupported, completionsSupported,
@@ -161,8 +169,7 @@ const App = () => {
sseUrl, sseUrl,
env, env,
bearerToken, bearerToken,
proxyServerUrl: getMCPProxyAddress(config), config,
requestTimeout: getMCPServerRequestTimeout(config),
onNotification: (notification) => { onNotification: (notification) => {
setNotifications((prev) => [...prev, notification as ServerNotification]); setNotifications((prev) => [...prev, notification as ServerNotification]);
}, },
@@ -279,13 +286,13 @@ const App = () => {
setErrors((prev) => ({ ...prev, [tabKey]: null })); setErrors((prev) => ({ ...prev, [tabKey]: null }));
}; };
const makeRequest = async <T extends z.ZodType>( const sendMCPRequest = async <T extends z.ZodType>(
request: ClientRequest, request: ClientRequest,
schema: T, schema: T,
tabKey?: keyof typeof errors, tabKey?: keyof typeof errors,
) => { ) => {
try { try {
const response = await makeConnectionRequest(request, schema); const response = await makeRequest(request, schema);
if (tabKey !== undefined) { if (tabKey !== undefined) {
clearError(tabKey); clearError(tabKey);
} }
@@ -303,7 +310,7 @@ const App = () => {
}; };
const listResources = async () => { const listResources = async () => {
const response = await makeRequest( const response = await sendMCPRequest(
{ {
method: "resources/list" as const, method: "resources/list" as const,
params: nextResourceCursor ? { cursor: nextResourceCursor } : {}, params: nextResourceCursor ? { cursor: nextResourceCursor } : {},
@@ -316,7 +323,7 @@ const App = () => {
}; };
const listResourceTemplates = async () => { const listResourceTemplates = async () => {
const response = await makeRequest( const response = await sendMCPRequest(
{ {
method: "resources/templates/list" as const, method: "resources/templates/list" as const,
params: nextResourceTemplateCursor params: nextResourceTemplateCursor
@@ -333,7 +340,7 @@ const App = () => {
}; };
const readResource = async (uri: string) => { const readResource = async (uri: string) => {
const response = await makeRequest( const response = await sendMCPRequest(
{ {
method: "resources/read" as const, method: "resources/read" as const,
params: { uri }, params: { uri },
@@ -346,7 +353,7 @@ const App = () => {
const subscribeToResource = async (uri: string) => { const subscribeToResource = async (uri: string) => {
if (!resourceSubscriptions.has(uri)) { if (!resourceSubscriptions.has(uri)) {
await makeRequest( await sendMCPRequest(
{ {
method: "resources/subscribe" as const, method: "resources/subscribe" as const,
params: { uri }, params: { uri },
@@ -362,7 +369,7 @@ const App = () => {
const unsubscribeFromResource = async (uri: string) => { const unsubscribeFromResource = async (uri: string) => {
if (resourceSubscriptions.has(uri)) { if (resourceSubscriptions.has(uri)) {
await makeRequest( await sendMCPRequest(
{ {
method: "resources/unsubscribe" as const, method: "resources/unsubscribe" as const,
params: { uri }, params: { uri },
@@ -377,7 +384,7 @@ const App = () => {
}; };
const listPrompts = async () => { const listPrompts = async () => {
const response = await makeRequest( const response = await sendMCPRequest(
{ {
method: "prompts/list" as const, method: "prompts/list" as const,
params: nextPromptCursor ? { cursor: nextPromptCursor } : {}, params: nextPromptCursor ? { cursor: nextPromptCursor } : {},
@@ -390,7 +397,7 @@ const App = () => {
}; };
const getPrompt = async (name: string, args: Record<string, string> = {}) => { const getPrompt = async (name: string, args: Record<string, string> = {}) => {
const response = await makeRequest( const response = await sendMCPRequest(
{ {
method: "prompts/get" as const, method: "prompts/get" as const,
params: { name, arguments: args }, params: { name, arguments: args },
@@ -402,7 +409,7 @@ const App = () => {
}; };
const listTools = async () => { const listTools = async () => {
const response = await makeRequest( const response = await sendMCPRequest(
{ {
method: "tools/list" as const, method: "tools/list" as const,
params: nextToolCursor ? { cursor: nextToolCursor } : {}, params: nextToolCursor ? { cursor: nextToolCursor } : {},
@@ -415,21 +422,34 @@ const App = () => {
}; };
const callTool = async (name: string, params: Record<string, unknown>) => { const callTool = async (name: string, params: Record<string, unknown>) => {
const response = await makeRequest( try {
{ const response = await sendMCPRequest(
method: "tools/call" as const, {
params: { method: "tools/call" as const,
name, params: {
arguments: params, name,
_meta: { arguments: params,
progressToken: progressTokenRef.current++, _meta: {
progressToken: progressTokenRef.current++,
},
}, },
}, },
}, CompatibilityCallToolResultSchema,
CompatibilityCallToolResultSchema, "tools",
"tools", );
); setToolResult(response);
setToolResult(response); } catch (e) {
const toolResult: CompatibilityCallToolResult = {
content: [
{
type: "text",
text: (e as Error).message ?? String(e),
},
],
isError: true,
};
setToolResult(toolResult);
}
}; };
const handleRootsChange = async () => { const handleRootsChange = async () => {
@@ -437,7 +457,7 @@ const App = () => {
}; };
const sendLogLevelRequest = async (level: LoggingLevel) => { const sendLogLevelRequest = async (level: LoggingLevel) => {
await makeRequest( await sendMCPRequest(
{ {
method: "logging/setLevel" as const, method: "logging/setLevel" as const,
params: { level }, params: { level },
@@ -637,9 +657,10 @@ const App = () => {
setTools([]); setTools([]);
setNextToolCursor(undefined); setNextToolCursor(undefined);
}} }}
callTool={(name, params) => { callTool={async (name, params) => {
clearError("tools"); clearError("tools");
callTool(name, params); setToolResult(null);
await callTool(name, params);
}} }}
selectedTool={selectedTool} selectedTool={selectedTool}
setSelectedTool={(tool) => { setSelectedTool={(tool) => {
@@ -654,7 +675,7 @@ const App = () => {
<ConsoleTab /> <ConsoleTab />
<PingTab <PingTab
onPingClick={() => { onPingClick={() => {
void makeRequest( void sendMCPRequest(
{ {
method: "ping" as const, method: "ping" as const,
}, },

View File

@@ -3,14 +3,16 @@ import { Button } from "@/components/ui/button";
const PingTab = ({ onPingClick }: { onPingClick: () => void }) => { const PingTab = ({ onPingClick }: { onPingClick: () => void }) => {
return ( return (
<TabsContent value="ping" className="grid grid-cols-2 gap-4"> <TabsContent value="ping">
<div className="col-span-2 flex justify-center items-center"> <div className="grid grid-cols-2 gap-4">
<Button <div className="col-span-2 flex justify-center items-center">
onClick={onPingClick} <Button
className="font-bold py-6 px-12 rounded-full" onClick={onPingClick}
> className="font-bold py-6 px-12 rounded-full"
Ping Server >
</Button> Ping Server
</Button>
</div>
</div> </div>
</TabsContent> </TabsContent>
); );

View File

@@ -84,84 +84,88 @@ const PromptsTab = ({
}; };
return ( return (
<TabsContent value="prompts" className="grid grid-cols-2 gap-4"> <TabsContent value="prompts">
<ListPane <div className="grid grid-cols-2 gap-4">
items={prompts} <ListPane
listItems={listPrompts} items={prompts}
clearItems={clearPrompts} listItems={listPrompts}
setSelectedItem={(prompt) => { clearItems={clearPrompts}
setSelectedPrompt(prompt); setSelectedItem={(prompt) => {
setPromptArgs({}); setSelectedPrompt(prompt);
}} setPromptArgs({});
renderItem={(prompt) => ( }}
<> renderItem={(prompt) => (
<span className="flex-1">{prompt.name}</span> <>
<span className="text-sm text-gray-500">{prompt.description}</span> <span className="flex-1">{prompt.name}</span>
</> <span className="text-sm text-gray-500">
)} {prompt.description}
title="Prompts" </span>
buttonText={nextCursor ? "List More Prompts" : "List Prompts"} </>
isButtonDisabled={!nextCursor && prompts.length > 0}
/>
<div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200">
<h3 className="font-semibold">
{selectedPrompt ? selectedPrompt.name : "Select a prompt"}
</h3>
</div>
<div className="p-4">
{error ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : selectedPrompt ? (
<div className="space-y-4">
{selectedPrompt.description && (
<p className="text-sm text-gray-600">
{selectedPrompt.description}
</p>
)}
{selectedPrompt.arguments?.map((arg) => (
<div key={arg.name}>
<Label htmlFor={arg.name}>{arg.name}</Label>
<Combobox
id={arg.name}
placeholder={`Enter ${arg.name}`}
value={promptArgs[arg.name] || ""}
onChange={(value) => handleInputChange(arg.name, value)}
onInputChange={(value) =>
handleInputChange(arg.name, value)
}
options={completions[arg.name] || []}
/>
{arg.description && (
<p className="text-xs text-gray-500 mt-1">
{arg.description}
{arg.required && (
<span className="text-xs mt-1 ml-1">(Required)</span>
)}
</p>
)}
</div>
))}
<Button onClick={handleGetPrompt} className="w-full">
Get Prompt
</Button>
{promptContent && (
<JsonView data={promptContent} withCopyButton={false} />
)}
</div>
) : (
<Alert>
<AlertDescription>
Select a prompt from the list to view and use it
</AlertDescription>
</Alert>
)} )}
title="Prompts"
buttonText={nextCursor ? "List More Prompts" : "List Prompts"}
isButtonDisabled={!nextCursor && prompts.length > 0}
/>
<div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200">
<h3 className="font-semibold">
{selectedPrompt ? selectedPrompt.name : "Select a prompt"}
</h3>
</div>
<div className="p-4">
{error ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : selectedPrompt ? (
<div className="space-y-4">
{selectedPrompt.description && (
<p className="text-sm text-gray-600">
{selectedPrompt.description}
</p>
)}
{selectedPrompt.arguments?.map((arg) => (
<div key={arg.name}>
<Label htmlFor={arg.name}>{arg.name}</Label>
<Combobox
id={arg.name}
placeholder={`Enter ${arg.name}`}
value={promptArgs[arg.name] || ""}
onChange={(value) => handleInputChange(arg.name, value)}
onInputChange={(value) =>
handleInputChange(arg.name, value)
}
options={completions[arg.name] || []}
/>
{arg.description && (
<p className="text-xs text-gray-500 mt-1">
{arg.description}
{arg.required && (
<span className="text-xs mt-1 ml-1">(Required)</span>
)}
</p>
)}
</div>
))}
<Button onClick={handleGetPrompt} className="w-full">
Get Prompt
</Button>
{promptContent && (
<JsonView data={promptContent} withCopyButton={false} />
)}
</div>
) : (
<Alert>
<AlertDescription>
Select a prompt from the list to view and use it
</AlertDescription>
</Alert>
)}
</div>
</div> </div>
</div> </div>
</TabsContent> </TabsContent>

View File

@@ -111,155 +111,158 @@ const ResourcesTab = ({
}; };
return ( return (
<TabsContent value="resources" className="grid grid-cols-3 gap-4"> <TabsContent value="resources">
<ListPane <div className="grid grid-cols-3 gap-4">
items={resources} <ListPane
listItems={listResources} items={resources}
clearItems={clearResources} listItems={listResources}
setSelectedItem={(resource) => { clearItems={clearResources}
setSelectedResource(resource); setSelectedItem={(resource) => {
readResource(resource.uri); setSelectedResource(resource);
setSelectedTemplate(null); readResource(resource.uri);
}} setSelectedTemplate(null);
renderItem={(resource) => ( }}
<div className="flex items-center w-full"> renderItem={(resource) => (
<FileText className="w-4 h-4 mr-2 flex-shrink-0 text-gray-500" /> <div className="flex items-center w-full">
<span className="flex-1 truncate" title={resource.uri.toString()}> <FileText className="w-4 h-4 mr-2 flex-shrink-0 text-gray-500" />
{resource.name} <span className="flex-1 truncate" title={resource.uri.toString()}>
</span> {resource.name}
<ChevronRight className="w-4 h-4 flex-shrink-0 text-gray-400" /> </span>
</div> <ChevronRight className="w-4 h-4 flex-shrink-0 text-gray-400" />
)}
title="Resources"
buttonText={nextCursor ? "List More Resources" : "List Resources"}
isButtonDisabled={!nextCursor && resources.length > 0}
/>
<ListPane
items={resourceTemplates}
listItems={listResourceTemplates}
clearItems={clearResourceTemplates}
setSelectedItem={(template) => {
setSelectedTemplate(template);
setSelectedResource(null);
setTemplateValues({});
}}
renderItem={(template) => (
<div className="flex items-center w-full">
<FileText className="w-4 h-4 mr-2 flex-shrink-0 text-gray-500" />
<span className="flex-1 truncate" title={template.uriTemplate}>
{template.name}
</span>
<ChevronRight className="w-4 h-4 flex-shrink-0 text-gray-400" />
</div>
)}
title="Resource Templates"
buttonText={
nextTemplateCursor ? "List More Templates" : "List Templates"
}
isButtonDisabled={!nextTemplateCursor && resourceTemplates.length > 0}
/>
<div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
<h3
className="font-semibold truncate"
title={selectedResource?.name || selectedTemplate?.name}
>
{selectedResource
? selectedResource.name
: selectedTemplate
? selectedTemplate.name
: "Select a resource or template"}
</h3>
{selectedResource && (
<div className="flex row-auto gap-1 justify-end w-2/5">
{resourceSubscriptionsSupported &&
!resourceSubscriptions.has(selectedResource.uri) && (
<Button
variant="outline"
size="sm"
onClick={() => subscribeToResource(selectedResource.uri)}
>
Subscribe
</Button>
)}
{resourceSubscriptionsSupported &&
resourceSubscriptions.has(selectedResource.uri) && (
<Button
variant="outline"
size="sm"
onClick={() =>
unsubscribeFromResource(selectedResource.uri)
}
>
Unsubscribe
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => readResource(selectedResource.uri)}
>
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
</div> </div>
)} )}
</div> title="Resources"
<div className="p-4"> buttonText={nextCursor ? "List More Resources" : "List Resources"}
{error ? ( isButtonDisabled={!nextCursor && resources.length > 0}
<Alert variant="destructive"> />
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle> <ListPane
<AlertDescription>{error}</AlertDescription> items={resourceTemplates}
</Alert> listItems={listResourceTemplates}
) : selectedResource ? ( clearItems={clearResourceTemplates}
<JsonView setSelectedItem={(template) => {
data={resourceContent} setSelectedTemplate(template);
className="bg-gray-50 dark:bg-gray-800 p-4 rounded text-sm overflow-auto max-h-96 text-gray-900 dark:text-gray-100" setSelectedResource(null);
/> setTemplateValues({});
) : selectedTemplate ? ( }}
<div className="space-y-4"> renderItem={(template) => (
<p className="text-sm text-gray-600"> <div className="flex items-center w-full">
{selectedTemplate.description} <FileText className="w-4 h-4 mr-2 flex-shrink-0 text-gray-500" />
</p> <span className="flex-1 truncate" title={template.uriTemplate}>
{selectedTemplate.uriTemplate {template.name}
.match(/{([^}]+)}/g) </span>
?.map((param) => { <ChevronRight className="w-4 h-4 flex-shrink-0 text-gray-400" />
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>
);
})}
<Button
onClick={handleReadTemplateResource}
disabled={Object.keys(templateValues).length === 0}
>
Read Resource
</Button>
</div> </div>
) : (
<Alert>
<AlertDescription>
Select a resource or template from the list to view its contents
</AlertDescription>
</Alert>
)} )}
title="Resource Templates"
buttonText={
nextTemplateCursor ? "List More Templates" : "List Templates"
}
isButtonDisabled={!nextTemplateCursor && resourceTemplates.length > 0}
/>
<div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
<h3
className="font-semibold truncate"
title={selectedResource?.name || selectedTemplate?.name}
>
{selectedResource
? selectedResource.name
: selectedTemplate
? selectedTemplate.name
: "Select a resource or template"}
</h3>
{selectedResource && (
<div className="flex row-auto gap-1 justify-end w-2/5">
{resourceSubscriptionsSupported &&
!resourceSubscriptions.has(selectedResource.uri) && (
<Button
variant="outline"
size="sm"
onClick={() => subscribeToResource(selectedResource.uri)}
>
Subscribe
</Button>
)}
{resourceSubscriptionsSupported &&
resourceSubscriptions.has(selectedResource.uri) && (
<Button
variant="outline"
size="sm"
onClick={() =>
unsubscribeFromResource(selectedResource.uri)
}
>
Unsubscribe
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => readResource(selectedResource.uri)}
>
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
</div>
)}
</div>
<div className="p-4">
{error ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : selectedResource ? (
<JsonView
data={resourceContent}
className="bg-gray-50 dark:bg-gray-800 p-4 rounded text-sm overflow-auto max-h-96 text-gray-900 dark:text-gray-100"
/>
) : selectedTemplate ? (
<div className="space-y-4">
<p className="text-sm text-gray-600">
{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>
);
})}
<Button
onClick={handleReadTemplateResource}
disabled={Object.keys(templateValues).length === 0}
>
Read Resource
</Button>
</div>
) : (
<Alert>
<AlertDescription>
Select a resource or template from the list to view its
contents
</AlertDescription>
</Alert>
)}
</div>
</div> </div>
</div> </div>
</TabsContent> </TabsContent>

View File

@@ -35,40 +35,42 @@ const RootsTab = ({
}; };
return ( return (
<TabsContent value="roots" className="space-y-4"> <TabsContent value="roots">
<Alert> <div className="space-y-4">
<AlertDescription> <Alert>
Configure the root directories that the server can access <AlertDescription>
</AlertDescription> Configure the root directories that the server can access
</Alert> </AlertDescription>
</Alert>
{roots.map((root, index) => ( {roots.map((root, index) => (
<div key={index} className="flex gap-2 items-center"> <div key={index} className="flex gap-2 items-center">
<Input <Input
placeholder="file:// URI" placeholder="file:// URI"
value={root.uri} value={root.uri}
onChange={(e) => updateRoot(index, "uri", e.target.value)} onChange={(e) => updateRoot(index, "uri", e.target.value)}
className="flex-1" className="flex-1"
/> />
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
onClick={() => removeRoot(index)} onClick={() => removeRoot(index)}
> >
<Minus className="h-4 w-4" /> <Minus className="h-4 w-4" />
</Button>
</div>
))}
<div className="flex gap-2">
<Button variant="outline" onClick={addRoot}>
<Plus className="h-4 w-4 mr-2" />
Add Root
</Button>
<Button onClick={handleSave}>
<Save className="h-4 w-4 mr-2" />
Save Changes
</Button> </Button>
</div> </div>
))}
<div className="flex gap-2">
<Button variant="outline" onClick={addRoot}>
<Plus className="h-4 w-4 mr-2" />
Add Root
</Button>
<Button onClick={handleSave}>
<Save className="h-4 w-4 mr-2" />
Save Changes
</Button>
</div> </div>
</TabsContent> </TabsContent>
); );

View File

@@ -33,33 +33,37 @@ const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
}; };
return ( return (
<TabsContent value="sampling" className="h-96"> <TabsContent value="sampling">
<Alert> <div className="h-96">
<AlertDescription> <Alert>
When the server requests LLM sampling, requests will appear here for <AlertDescription>
approval. When the server requests LLM sampling, requests will appear here for
</AlertDescription> approval.
</Alert> </AlertDescription>
<div className="mt-4 space-y-4"> </Alert>
<h3 className="text-lg font-semibold">Recent Requests</h3> <div className="mt-4 space-y-4">
{pendingRequests.map((request) => ( <h3 className="text-lg font-semibold">Recent Requests</h3>
<div key={request.id} className="p-4 border rounded-lg space-y-4"> {pendingRequests.map((request) => (
<JsonView <div key={request.id} className="p-4 border rounded-lg space-y-4">
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 rounded" <JsonView
data={JSON.stringify(request.request)} className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 rounded"
/> data={JSON.stringify(request.request)}
/>
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button onClick={() => handleApprove(request.id)}>Approve</Button> <Button onClick={() => handleApprove(request.id)}>
<Button variant="outline" onClick={() => onReject(request.id)}> Approve
Reject </Button>
</Button> <Button variant="outline" onClick={() => onReject(request.id)}>
Reject
</Button>
</div>
</div> </div>
</div> ))}
))} {pendingRequests.length === 0 && (
{pendingRequests.length === 0 && ( <p className="text-gray-500">No pending requests</p>
<p className="text-gray-500">No pending requests</p> )}
)} </div>
</div> </div>
</TabsContent> </TabsContent>
); );

View File

@@ -325,8 +325,8 @@ const Sidebar = ({
return ( return (
<div key={key} className="space-y-2"> <div key={key} className="space-y-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<label className="text-sm font-medium text-green-600"> <label className="text-sm font-medium text-green-600 break-all">
{configKey} {configItem.label}
</label> </label>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>

View File

@@ -13,7 +13,7 @@ import {
ListToolsResult, ListToolsResult,
Tool, Tool,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import { Send } from "lucide-react"; import { Loader2, Send } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import ListPane from "./ListPane"; import ListPane from "./ListPane";
import JsonView from "./JsonView"; import JsonView from "./JsonView";
@@ -31,7 +31,7 @@ const ToolsTab = ({
tools: Tool[]; tools: Tool[];
listTools: () => void; listTools: () => void;
clearTools: () => void; clearTools: () => void;
callTool: (name: string, params: Record<string, unknown>) => void; callTool: (name: string, params: Record<string, unknown>) => Promise<void>;
selectedTool: Tool | null; selectedTool: Tool | null;
setSelectedTool: (tool: Tool | null) => void; setSelectedTool: (tool: Tool | null) => void;
toolResult: CompatibilityCallToolResult | null; toolResult: CompatibilityCallToolResult | null;
@@ -39,6 +39,8 @@ const ToolsTab = ({
error: string | null; error: string | null;
}) => { }) => {
const [params, setParams] = useState<Record<string, unknown>>({}); const [params, setParams] = useState<Record<string, unknown>>({});
const [isToolRunning, setIsToolRunning] = useState(false);
useEffect(() => { useEffect(() => {
const params = Object.entries( const params = Object.entries(
selectedTool?.inputSchema.properties ?? [], selectedTool?.inputSchema.properties ?? [],
@@ -112,155 +114,180 @@ const ToolsTab = ({
}; };
return ( return (
<TabsContent value="tools" className="grid grid-cols-2 gap-4"> <TabsContent value="tools">
<ListPane <div className="grid grid-cols-2 gap-4">
items={tools} <ListPane
listItems={listTools} items={tools}
clearItems={() => { listItems={listTools}
clearTools(); clearItems={() => {
setSelectedTool(null); clearTools();
}} setSelectedTool(null);
setSelectedItem={setSelectedTool} }}
renderItem={(tool) => ( setSelectedItem={setSelectedTool}
<> renderItem={(tool) => (
<span className="flex-1">{tool.name}</span> <>
<span className="text-sm text-gray-500 text-right"> <span className="flex-1">{tool.name}</span>
{tool.description} <span className="text-sm text-gray-500 text-right">
</span> {tool.description}
</> </span>
)} </>
title="Tools" )}
buttonText={nextCursor ? "List More Tools" : "List Tools"} title="Tools"
isButtonDisabled={!nextCursor && tools.length > 0} buttonText={nextCursor ? "List More Tools" : "List Tools"}
/> isButtonDisabled={!nextCursor && tools.length > 0}
/>
<div className="bg-card rounded-lg shadow"> <div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200"> <div className="p-4 border-b border-gray-200">
<h3 className="font-semibold"> <h3 className="font-semibold">
{selectedTool ? selectedTool.name : "Select a tool"} {selectedTool ? selectedTool.name : "Select a tool"}
</h3> </h3>
</div> </div>
<div className="p-4"> <div className="p-4">
{selectedTool ? ( {selectedTool ? (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
{selectedTool.description} {selectedTool.description}
</p> </p>
{Object.entries(selectedTool.inputSchema.properties ?? []).map( {Object.entries(selectedTool.inputSchema.properties ?? []).map(
([key, value]) => { ([key, value]) => {
const prop = value as JsonSchemaType; const prop = value as JsonSchemaType;
return ( return (
<div key={key}> <div key={key}>
<Label <Label
htmlFor={key} htmlFor={key}
className="block text-sm font-medium text-gray-700" className="block text-sm font-medium text-gray-700"
> >
{key} {key}
</Label> </Label>
{prop.type === "boolean" ? ( {prop.type === "boolean" ? (
<div className="flex items-center space-x-2 mt-2"> <div className="flex items-center space-x-2 mt-2">
<Checkbox <Checkbox
id={key}
name={key}
checked={!!params[key]}
onCheckedChange={(checked: boolean) =>
setParams({
...params,
[key]: checked,
})
}
/>
<label
htmlFor={key}
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{prop.description || "Toggle this option"}
</label>
</div>
) : prop.type === "string" ? (
<Textarea
id={key} id={key}
name={key} name={key}
checked={!!params[key]} placeholder={prop.description}
onCheckedChange={(checked: boolean) => value={(params[key] as string) ?? ""}
onChange={(e) =>
setParams({ setParams({
...params, ...params,
[key]: checked, [key]: e.target.value,
}) })
} }
className="mt-1"
/> />
<label ) : prop.type === "object" || prop.type === "array" ? (
htmlFor={key} <div className="mt-1">
className="text-sm font-medium text-gray-700 dark:text-gray-300" <DynamicJsonForm
> schema={{
{prop.description || "Toggle this option"} type: prop.type,
</label> properties: prop.properties,
</div> description: prop.description,
) : prop.type === "string" ? ( items: prop.items,
<Textarea }}
id={key} value={
name={key} (params[key] as JsonValue) ??
placeholder={prop.description} generateDefaultValue(prop)
value={(params[key] as string) ?? ""} }
onChange={(e) => onChange={(newValue: JsonValue) => {
setParams({ setParams({
...params, ...params,
[key]: e.target.value, [key]: newValue,
}) });
} }}
className="mt-1" />
/> </div>
) : prop.type === "object" || prop.type === "array" ? ( ) : prop.type === "number" ||
<div className="mt-1"> prop.type === "integer" ? (
<DynamicJsonForm <Input
schema={{ type="number"
type: prop.type, id={key}
properties: prop.properties, name={key}
description: prop.description, placeholder={prop.description}
items: prop.items, value={(params[key] as string) ?? ""}
}} onChange={(e) =>
value={params[key] as JsonValue}
onChange={(newValue: JsonValue) => {
setParams({ setParams({
...params, ...params,
[key]: newValue, [key]: Number(e.target.value),
}); })
}} }
className="mt-1"
/> />
</div> ) : (
) : prop.type === "number" || prop.type === "integer" ? ( <div className="mt-1">
<Input <DynamicJsonForm
type="number" onlyJSON
id={key} schema={{
name={key} type: prop.type,
placeholder={prop.description} properties: prop.properties,
value={(params[key] as string) ?? ""} description: prop.description,
onChange={(e) => items: prop.items,
setParams({ }}
...params, value={params[key] as JsonValue}
[key]: Number(e.target.value), onChange={(newValue: JsonValue) => {
}) setParams({
} ...params,
className="mt-1" [key]: newValue,
/> });
) : ( }}
<div className="mt-1"> />
<DynamicJsonForm </div>
onlyJSON )}
schema={{ </div>
type: prop.type, );
properties: prop.properties, },
description: prop.description, )}
items: prop.items, <Button
}} onClick={async () => {
value={params[key] as JsonValue} try {
onChange={(newValue: JsonValue) => { setIsToolRunning(true);
setParams({ await callTool(selectedTool.name, params);
...params, } finally {
[key]: newValue, setIsToolRunning(false);
}); }
}} }}
/> disabled={isToolRunning}
</div> >
)} {isToolRunning ? (
</div> <>
); <Loader2 className="w-4 h-4 mr-2 animate-spin" />
}, Running...
)} </>
<Button onClick={() => callTool(selectedTool.name, params)}> ) : (
<Send className="w-4 h-4 mr-2" /> <>
Run Tool <Send className="w-4 h-4 mr-2" />
</Button> Run Tool
{toolResult && renderToolResult()} </>
</div> )}
) : ( </Button>
<Alert> {toolResult && renderToolResult()}
<AlertDescription> </div>
Select a tool from the list to view its details and run it ) : (
</AlertDescription> <Alert>
</Alert> <AlertDescription>
)} Select a tool from the list to view its details and run it
</AlertDescription>
</Alert>
)}
</div>
</div> </div>
</div> </div>
</TabsContent> </TabsContent>

View File

@@ -343,6 +343,7 @@ describe("Sidebar Environment Variables", () => {
expect(setConfig).toHaveBeenCalledWith( expect(setConfig).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
MCP_SERVER_REQUEST_TIMEOUT: { MCP_SERVER_REQUEST_TIMEOUT: {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)", description: "Timeout for requests to the MCP server (ms)",
value: 5000, value: 5000,
}, },
@@ -350,6 +351,56 @@ describe("Sidebar Environment Variables", () => {
); );
}); });
it("should update MCP server proxy address", () => {
const setConfig = jest.fn();
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
openConfigSection();
const proxyAddressInput = screen.getByTestId(
"MCP_PROXY_FULL_ADDRESS-input",
);
fireEvent.change(proxyAddressInput, {
target: { value: "http://localhost:8080" },
});
expect(setConfig).toHaveBeenCalledWith(
expect.objectContaining({
MCP_PROXY_FULL_ADDRESS: {
label: "Inspector Proxy Address",
description:
"Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577",
value: "http://localhost:8080",
},
}),
);
});
it("should update max total timeout", () => {
const setConfig = jest.fn();
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
openConfigSection();
const maxTotalTimeoutInput = screen.getByTestId(
"MCP_REQUEST_MAX_TOTAL_TIMEOUT-input",
);
fireEvent.change(maxTotalTimeoutInput, {
target: { value: "10000" },
});
expect(setConfig).toHaveBeenCalledWith(
expect.objectContaining({
MCP_REQUEST_MAX_TOTAL_TIMEOUT: {
label: "Maximum Total Timeout",
description:
"Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)",
value: 10000,
},
}),
);
});
it("should handle invalid timeout values entered by user", () => { it("should handle invalid timeout values entered by user", () => {
const setConfig = jest.fn(); const setConfig = jest.fn();
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig }); renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
@@ -364,6 +415,7 @@ describe("Sidebar Environment Variables", () => {
expect(setConfig).toHaveBeenCalledWith( expect(setConfig).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
MCP_SERVER_REQUEST_TIMEOUT: { MCP_SERVER_REQUEST_TIMEOUT: {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)", description: "Timeout for requests to the MCP server (ms)",
value: 0, value: 0,
}, },
@@ -409,6 +461,7 @@ describe("Sidebar Environment Variables", () => {
expect(setConfig).toHaveBeenLastCalledWith( expect(setConfig).toHaveBeenLastCalledWith(
expect.objectContaining({ expect.objectContaining({
MCP_SERVER_REQUEST_TIMEOUT: { MCP_SERVER_REQUEST_TIMEOUT: {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)", description: "Timeout for requests to the MCP server (ms)",
value: 3000, value: 3000,
}, },

View File

@@ -1,4 +1,4 @@
import { render, screen, fireEvent } from "@testing-library/react"; import { render, screen, fireEvent, act } from "@testing-library/react";
import { describe, it, expect, jest } from "@jest/globals"; import { describe, it, expect, jest } from "@jest/globals";
import "@testing-library/jest-dom"; import "@testing-library/jest-dom";
import ToolsTab from "../ToolsTab"; import ToolsTab from "../ToolsTab";
@@ -43,7 +43,7 @@ describe("ToolsTab", () => {
tools: mockTools, tools: mockTools,
listTools: jest.fn(), listTools: jest.fn(),
clearTools: jest.fn(), clearTools: jest.fn(),
callTool: jest.fn(), callTool: jest.fn(async () => {}),
selectedTool: null, selectedTool: null,
setSelectedTool: jest.fn(), setSelectedTool: jest.fn(),
toolResult: null, toolResult: null,
@@ -59,14 +59,16 @@ describe("ToolsTab", () => {
); );
}; };
it("should reset input values when switching tools", () => { it("should reset input values when switching tools", async () => {
const { rerender } = renderToolsTab({ const { rerender } = renderToolsTab({
selectedTool: mockTools[0], selectedTool: mockTools[0],
}); });
// Enter a value in the first tool's input // Enter a value in the first tool's input
const input = screen.getByRole("spinbutton") as HTMLInputElement; const input = screen.getByRole("spinbutton") as HTMLInputElement;
fireEvent.change(input, { target: { value: "42" } }); await act(async () => {
fireEvent.change(input, { target: { value: "42" } });
});
expect(input.value).toBe("42"); expect(input.value).toBe("42");
// Switch to second tool // Switch to second tool
@@ -80,7 +82,8 @@ describe("ToolsTab", () => {
const newInput = screen.getByRole("spinbutton") as HTMLInputElement; const newInput = screen.getByRole("spinbutton") as HTMLInputElement;
expect(newInput.value).toBe(""); expect(newInput.value).toBe("");
}); });
it("should handle integer type inputs", () => {
it("should handle integer type inputs", async () => {
renderToolsTab({ renderToolsTab({
selectedTool: mockTools[1], // Use the tool with integer type selectedTool: mockTools[1], // Use the tool with integer type
}); });
@@ -93,10 +96,49 @@ describe("ToolsTab", () => {
expect(input.value).toBe("42"); expect(input.value).toBe("42");
const submitButton = screen.getByRole("button", { name: /run tool/i }); const submitButton = screen.getByRole("button", { name: /run tool/i });
fireEvent.click(submitButton); await act(async () => {
fireEvent.click(submitButton);
});
expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[1].name, { expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[1].name, {
count: 42, count: 42,
}); });
}); });
it("should disable button and change text while tool is running", async () => {
// Create a promise that we can resolve later
let resolvePromise: ((value: unknown) => void) | undefined;
const mockPromise = new Promise((resolve) => {
resolvePromise = resolve;
});
// Mock callTool to return our promise
const mockCallTool = jest.fn().mockReturnValue(mockPromise);
renderToolsTab({
selectedTool: mockTools[0],
callTool: mockCallTool,
});
const submitButton = screen.getByRole("button", { name: /run tool/i });
expect(submitButton.getAttribute("disabled")).toBeNull();
// Click the button and verify immediate state changes
await act(async () => {
fireEvent.click(submitButton);
});
// Verify button is disabled and text changed
expect(submitButton.getAttribute("disabled")).not.toBeNull();
expect(submitButton.textContent).toBe("Running...");
// Resolve the promise to simulate tool completion
await act(async () => {
if (resolvePromise) {
await resolvePromise({});
}
});
expect(submitButton.getAttribute("disabled")).toBeNull();
});
}); });

View File

@@ -1,4 +1,5 @@
export type ConfigItem = { export type ConfigItem = {
label: string;
description: string; description: string;
value: string | number | boolean; value: string | number | boolean;
}; };
@@ -15,5 +16,21 @@ export type InspectorConfig = {
* Maximum time in milliseconds to wait for a response from the MCP server before timing out. * Maximum time in milliseconds to wait for a response from the MCP server before timing out.
*/ */
MCP_SERVER_REQUEST_TIMEOUT: ConfigItem; MCP_SERVER_REQUEST_TIMEOUT: ConfigItem;
/**
* Whether to reset the timeout on progress notifications. Useful for long-running operations that send periodic progress updates.
* Refer: https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/utilities/progress/#progress-flow
*/
MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS: ConfigItem;
/**
* Maximum total time in milliseconds to wait for a response from the MCP server before timing out. Used in conjunction with MCP_SERVER_REQUEST_TIMEOUT_RESET_ON_PROGRESS.
* Refer: https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/utilities/progress/#progress-flow
*/
MCP_REQUEST_MAX_TOTAL_TIMEOUT: ConfigItem;
/**
* The full address of the MCP Proxy Server, in case it is running on a non-default address. Example: http://10.1.1.22:5577
*/
MCP_PROXY_FULL_ADDRESS: ConfigItem; MCP_PROXY_FULL_ADDRESS: ConfigItem;
}; };

View File

@@ -22,10 +22,23 @@ export const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277";
**/ **/
export const DEFAULT_INSPECTOR_CONFIG: InspectorConfig = { export const DEFAULT_INSPECTOR_CONFIG: InspectorConfig = {
MCP_SERVER_REQUEST_TIMEOUT: { MCP_SERVER_REQUEST_TIMEOUT: {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)", description: "Timeout for requests to the MCP server (ms)",
value: 10000, value: 10000,
}, },
MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS: {
label: "Reset Timeout on Progress",
description: "Reset timeout on progress notifications",
value: true,
},
MCP_REQUEST_MAX_TOTAL_TIMEOUT: {
label: "Maximum Total Timeout",
description:
"Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)",
value: 60000,
},
MCP_PROXY_FULL_ADDRESS: { MCP_PROXY_FULL_ADDRESS: {
label: "Inspector Proxy Address",
description: description:
"Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577", "Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577",
value: "", value: "",

View File

@@ -0,0 +1,164 @@
import { renderHook, act } from "@testing-library/react";
import { useConnection } from "../useConnection";
import { z } from "zod";
import { ClientRequest } from "@modelcontextprotocol/sdk/types.js";
import { DEFAULT_INSPECTOR_CONFIG } from "../../constants";
// Mock fetch
global.fetch = jest.fn().mockResolvedValue({
json: () => Promise.resolve({ status: "ok" }),
});
// Mock the SDK dependencies
const mockRequest = jest.fn().mockResolvedValue({ test: "response" });
const mockClient = {
request: mockRequest,
notification: jest.fn(),
connect: jest.fn().mockResolvedValue(undefined),
close: jest.fn(),
getServerCapabilities: jest.fn(),
setNotificationHandler: jest.fn(),
setRequestHandler: jest.fn(),
};
jest.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
Client: jest.fn().mockImplementation(() => mockClient),
}));
jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({
SSEClientTransport: jest.fn(),
SseError: jest.fn(),
}));
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
auth: jest.fn().mockResolvedValue("AUTHORIZED"),
}));
// Mock the toast hook
jest.mock("@/hooks/use-toast", () => ({
useToast: () => ({
toast: jest.fn(),
}),
}));
// Mock the auth provider
jest.mock("../../auth", () => ({
authProvider: {
tokens: jest.fn().mockResolvedValue({ access_token: "mock-token" }),
},
}));
describe("useConnection", () => {
const defaultProps = {
transportType: "sse" as const,
command: "",
args: "",
sseUrl: "http://localhost:8080",
env: {},
config: DEFAULT_INSPECTOR_CONFIG,
};
describe("Request Configuration", () => {
beforeEach(() => {
jest.clearAllMocks();
});
test("uses the default config values in makeRequest", async () => {
const { result } = renderHook(() => useConnection(defaultProps));
// Connect the client
await act(async () => {
await result.current.connect();
});
// Wait for state update
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
const mockRequest: ClientRequest = {
method: "ping",
params: {},
};
const mockSchema = z.object({
test: z.string(),
});
await act(async () => {
await result.current.makeRequest(mockRequest, mockSchema);
});
expect(mockClient.request).toHaveBeenCalledWith(
mockRequest,
mockSchema,
expect.objectContaining({
timeout: DEFAULT_INSPECTOR_CONFIG.MCP_SERVER_REQUEST_TIMEOUT.value,
maxTotalTimeout:
DEFAULT_INSPECTOR_CONFIG.MCP_REQUEST_MAX_TOTAL_TIMEOUT.value,
resetTimeoutOnProgress:
DEFAULT_INSPECTOR_CONFIG.MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS
.value,
}),
);
});
test("overrides the default config values when passed in options in makeRequest", async () => {
const { result } = renderHook(() => useConnection(defaultProps));
// Connect the client
await act(async () => {
await result.current.connect();
});
// Wait for state update
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
const mockRequest: ClientRequest = {
method: "ping",
params: {},
};
const mockSchema = z.object({
test: z.string(),
});
await act(async () => {
await result.current.makeRequest(mockRequest, mockSchema, {
timeout: 1000,
maxTotalTimeout: 2000,
resetTimeoutOnProgress: false,
});
});
expect(mockClient.request).toHaveBeenCalledWith(
mockRequest,
mockSchema,
expect.objectContaining({
timeout: 1000,
maxTotalTimeout: 2000,
resetTimeoutOnProgress: false,
}),
);
});
});
test("throws error when mcpClient is not connected", async () => {
const { result } = renderHook(() => useConnection(defaultProps));
const mockRequest: ClientRequest = {
method: "ping",
params: {},
};
const mockSchema = z.object({
test: z.string(),
});
await expect(
result.current.makeRequest(mockRequest, mockSchema),
).rejects.toThrow("MCP client not connected");
});
});

View File

@@ -8,7 +8,6 @@ import {
ClientRequest, ClientRequest,
CreateMessageRequestSchema, CreateMessageRequestSchema,
ListRootsRequestSchema, ListRootsRequestSchema,
ProgressNotificationSchema,
ResourceUpdatedNotificationSchema, ResourceUpdatedNotificationSchema,
LoggingMessageNotificationSchema, LoggingMessageNotificationSchema,
Request, Request,
@@ -23,7 +22,9 @@ import {
ResourceListChangedNotificationSchema, ResourceListChangedNotificationSchema,
ToolListChangedNotificationSchema, ToolListChangedNotificationSchema,
PromptListChangedNotificationSchema, PromptListChangedNotificationSchema,
Progress,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js";
import { useState } from "react"; import { useState } from "react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { z } from "zod"; import { z } from "zod";
@@ -32,6 +33,13 @@ import { Notification, StdErrNotificationSchema } from "../notificationTypes";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { authProvider } from "../auth"; import { authProvider } from "../auth";
import packageJson from "../../../package.json"; import packageJson from "../../../package.json";
import {
getMCPProxyAddress,
getMCPServerRequestMaxTotalTimeout,
resetRequestTimeoutOnProgress,
} from "@/utils/configUtils";
import { getMCPServerRequestTimeout } from "@/utils/configUtils";
import { InspectorConfig } from "../configurationTypes";
interface UseConnectionOptions { interface UseConnectionOptions {
transportType: "stdio" | "sse"; transportType: "stdio" | "sse";
@@ -39,9 +47,8 @@ interface UseConnectionOptions {
args: string; args: string;
sseUrl: string; sseUrl: string;
env: Record<string, string>; env: Record<string, string>;
proxyServerUrl: string;
bearerToken?: string; bearerToken?: string;
requestTimeout?: number; config: InspectorConfig;
onNotification?: (notification: Notification) => void; onNotification?: (notification: Notification) => void;
onStdErrNotification?: (notification: Notification) => void; onStdErrNotification?: (notification: Notification) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -50,21 +57,14 @@ interface UseConnectionOptions {
getRoots?: () => any[]; getRoots?: () => any[];
} }
interface RequestOptions {
signal?: AbortSignal;
timeout?: number;
suppressToast?: boolean;
}
export function useConnection({ export function useConnection({
transportType, transportType,
command, command,
args, args,
sseUrl, sseUrl,
env, env,
proxyServerUrl,
bearerToken, bearerToken,
requestTimeout, config,
onNotification, onNotification,
onStdErrNotification, onStdErrNotification,
onPendingRequest, onPendingRequest,
@@ -94,31 +94,50 @@ export function useConnection({
const makeRequest = async <T extends z.ZodType>( const makeRequest = async <T extends z.ZodType>(
request: ClientRequest, request: ClientRequest,
schema: T, schema: T,
options?: RequestOptions, options?: RequestOptions & { suppressToast?: boolean },
): Promise<z.output<T>> => { ): Promise<z.output<T>> => {
if (!mcpClient) { if (!mcpClient) {
throw new Error("MCP client not connected"); throw new Error("MCP client not connected");
} }
try { try {
const abortController = new AbortController(); const abortController = new AbortController();
const timeoutId = setTimeout(() => {
abortController.abort("Request timed out"); // prepare MCP Client request options
}, options?.timeout ?? requestTimeout); const mcpRequestOptions: RequestOptions = {
signal: options?.signal ?? abortController.signal,
resetTimeoutOnProgress:
options?.resetTimeoutOnProgress ??
resetRequestTimeoutOnProgress(config),
timeout: options?.timeout ?? getMCPServerRequestTimeout(config),
maxTotalTimeout:
options?.maxTotalTimeout ??
getMCPServerRequestMaxTotalTimeout(config),
};
// If progress notifications are enabled, add an onprogress hook to the MCP Client request options
// This is required by SDK to reset the timeout on progress notifications
if (mcpRequestOptions.resetTimeoutOnProgress) {
mcpRequestOptions.onprogress = (params: Progress) => {
// Add progress notification to `Server Notification` window in the UI
if (onNotification) {
onNotification({
method: "notification/progress",
params,
});
}
};
}
let response; let response;
try { try {
response = await mcpClient.request(request, schema, { response = await mcpClient.request(request, schema, mcpRequestOptions);
signal: options?.signal ?? abortController.signal,
});
pushHistory(request, response); pushHistory(request, response);
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);
pushHistory(request, { error: errorMessage }); pushHistory(request, { error: errorMessage });
throw error; throw error;
} finally {
clearTimeout(timeoutId);
} }
return response; return response;
@@ -211,7 +230,7 @@ export function useConnection({
const checkProxyHealth = async () => { const checkProxyHealth = async () => {
try { try {
const proxyHealthUrl = new URL(`${proxyServerUrl}/health`); const proxyHealthUrl = new URL(`${getMCPProxyAddress(config)}/health`);
const proxyHealthResponse = await fetch(proxyHealthUrl); const proxyHealthResponse = await fetch(proxyHealthUrl);
const proxyHealth = await proxyHealthResponse.json(); const proxyHealth = await proxyHealthResponse.json();
if (proxyHealth?.status !== "ok") { if (proxyHealth?.status !== "ok") {
@@ -256,7 +275,7 @@ export function useConnection({
setConnectionStatus("error-connecting-to-proxy"); setConnectionStatus("error-connecting-to-proxy");
return; return;
} }
const mcpProxyServerUrl = new URL(`${proxyServerUrl}/sse`); const mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`);
mcpProxyServerUrl.searchParams.append("transportType", transportType); mcpProxyServerUrl.searchParams.append("transportType", transportType);
if (transportType === "stdio") { if (transportType === "stdio") {
mcpProxyServerUrl.searchParams.append("command", command); mcpProxyServerUrl.searchParams.append("command", command);
@@ -289,7 +308,6 @@ export function useConnection({
if (onNotification) { if (onNotification) {
[ [
CancelledNotificationSchema, CancelledNotificationSchema,
ProgressNotificationSchema,
LoggingMessageNotificationSchema, LoggingMessageNotificationSchema,
ResourceUpdatedNotificationSchema, ResourceUpdatedNotificationSchema,
ResourceListChangedNotificationSchema, ResourceListChangedNotificationSchema,

View File

@@ -12,3 +12,15 @@ export const getMCPProxyAddress = (config: InspectorConfig): string => {
export const getMCPServerRequestTimeout = (config: InspectorConfig): number => { export const getMCPServerRequestTimeout = (config: InspectorConfig): number => {
return config.MCP_SERVER_REQUEST_TIMEOUT.value as number; return config.MCP_SERVER_REQUEST_TIMEOUT.value as number;
}; };
export const resetRequestTimeoutOnProgress = (
config: InspectorConfig,
): boolean => {
return config.MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS.value as boolean;
};
export const getMCPServerRequestMaxTotalTimeout = (
config: InspectorConfig,
): number => {
return config.MCP_REQUEST_MAX_TOTAL_TIMEOUT.value as number;
};