Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 73 additions & 28 deletions mcpjam-inspector/client/src/components/ServersTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Button } from "./ui/button";
import {
Plus,
FileText,
FileSymlink,
Cable,
Link,
Loader2,
Expand Down Expand Up @@ -47,6 +48,7 @@ import {
import { CollapsedPanelStrip } from "./ui/collapsed-panel-strip";
import { LoggerView } from "./logger-view";
import { useJsonRpcPanelVisibility } from "@/hooks/use-json-rpc-panel";
import { formatJsonConfig } from "@/lib/json-config-parser";
import { Skeleton } from "./ui/skeleton";

interface ServersTabProps {
Expand Down Expand Up @@ -155,6 +157,35 @@ export function ServersTab({
setIsActionMenuOpen(false);
};

const downloadJson = (filename: string, data: any) => {
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
Comment on lines +160 to +171
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Defer URL revocation to avoid flaky downloads.

Revoking immediately after a.click() can cancel the download in some browsers. A short defer makes the flow more reliable.

🧩 Proposed fix
     a.click();
     document.body.removeChild(a);
-    URL.revokeObjectURL(url);
+    // Allow the browser to start the download before revoking
+    setTimeout(() => URL.revokeObjectURL(url), 0);
🤖 Prompt for AI Agents
In `@mcpjam-inspector/client/src/components/ServersTab.tsx` around lines 160 -
171, The downloadJson function revokes the object URL immediately after
a.click(), which can abort downloads in some browsers; change it to defer
revocation by moving URL.revokeObjectURL(url) into a short callback (e.g.,
setTimeout(() => URL.revokeObjectURL(url), 0) or attach to the anchor's
onload/onfocus event) so the file download completes reliably while still
cleaning up the created blob URL; update the logic in downloadJson accordingly.

};

const handleExportAsJsonClick = () => {
posthog.capture("export_servers_to_json_button_clicked", {
location: "servers_tab",
platform: detectPlatform(),
environment: detectEnvironment(),
});
const formattedJson = formatJsonConfig(connectedServerConfigs);
const timestamp = new Date()
.toISOString()
.split(".")[0]
.replace(/[T:]/g, "-");
const fileName = `mcp-servers-config-${timestamp}.json`;
downloadJson(fileName, formattedJson);
};

const handleCreateTunnel = () => {
posthog.capture("create_tunnel_button_clicked", {
location: "servers_tab",
Expand Down Expand Up @@ -309,43 +340,56 @@ export function ServersTab({
};

const renderServerActionsMenu = () => (
<HoverCard
open={isActionMenuOpen}
onOpenChange={setIsActionMenuOpen}
openDelay={150}
closeDelay={100}
>
<HoverCardTrigger asChild>
<>
{Object.keys(connectedServerConfigs ?? {}).length > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleAddServerClick}
className="cursor-pointer"
className="justify-start"
onClick={handleExportAsJsonClick}
>
<Plus className="h-4 w-4 mr-2" />
Add Server
<FileSymlink className="h-4 w-4 mr-2" />
Export Servers
</Button>
</HoverCardTrigger>
<HoverCardContent align="end" sideOffset={8} className="w-56 p-3">
<div className="flex flex-col gap-2">
)}
<HoverCard
open={isActionMenuOpen}
onOpenChange={setIsActionMenuOpen}
openDelay={150}
closeDelay={100}
>
<HoverCardTrigger asChild>
<Button
variant="ghost"
className="justify-start"
size="sm"
onClick={handleAddServerClick}
className="cursor-pointer"
>
<Plus className="h-4 w-4 mr-2" />
Add manually
</Button>
<Button
variant="ghost"
className="justify-start"
onClick={handleImportJsonClick}
>
<FileText className="h-4 w-4 mr-2" />
Import JSON
Add Server
</Button>
</div>
</HoverCardContent>
</HoverCard>
</HoverCardTrigger>
<HoverCardContent align="end" sideOffset={8} className="w-56 p-3">
<div className="flex flex-col gap-2">
<Button
variant="ghost"
className="justify-start"
onClick={handleAddServerClick}
>
<Plus className="h-4 w-4 mr-2" />
Add manually
</Button>
<Button
variant="ghost"
className="justify-start"
onClick={handleImportJsonClick}
>
<FileText className="h-4 w-4 mr-2" />
Import JSON
</Button>
</div>
</HoverCardContent>
</HoverCard>
</>
);

const renderConnectedContent = () => (
Expand Down Expand Up @@ -472,6 +516,7 @@ export function ServersTab({
: connectedCount > 0
? renderConnectedContent()
: renderEmptyContent()}
{connectedCount > 0 ? renderConnectedContent() : renderEmptyContent()}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

rg -n "isLoadingWorkspaces" --type=ts --type=tsx

Repository: MCPJam/inspector

Length of output: 87


🏁 Script executed:

# Get more context on ServersTab component definition
rg -A 15 "interface ServersTabProps" --type=ts --type=tsx

Repository: MCPJam/inspector

Length of output: 87


🏁 Script executed:

# Check for ServersTab usage/imports to find callers
rg -n "ServersTab" --type=ts --type=tsx | head -30

Repository: MCPJam/inspector

Length of output: 87


🏁 Script executed:

# Find all TypeScript/TSX files first
fd -e tsx -e ts | head -20

Repository: MCPJam/inspector

Length of output: 1171


🏁 Script executed:

# Search for isLoadingWorkspaces without type restrictions
rg "isLoadingWorkspaces"

Repository: MCPJam/inspector

Length of output: 813


🏁 Script executed:

# Look for ServersTab.tsx directly
fd -n "ServersTab.tsx"

Repository: MCPJam/inspector

Length of output: 290


🏁 Script executed:

# Find ServersTab.tsx without -n flag
fd "ServersTab.tsx"

Repository: MCPJam/inspector

Length of output: 113


🏁 Script executed:

# Check App.tsx to see how ServersTab is used
rg -A 2 -B 2 "ServersTab" mcpjam-inspector/client/src/App.tsx

Repository: MCPJam/inspector

Length of output: 516


🏁 Script executed:

# Read ServersTab.tsx to check the props interface and line 502
cat -n mcpjam-inspector/client/src/components/ServersTab.tsx | grep -A 30 "interface ServersTabProps"

Repository: MCPJam/inspector

Length of output: 1220


🏁 Script executed:

# Check around line 502 specifically
sed -n '495,510p' mcpjam-inspector/client/src/components/ServersTab.tsx

Repository: MCPJam/inspector

Length of output: 428


🏁 Script executed:

# Check the full App.tsx ServersTab usage to see if isLoadingWorkspaces is still passed
rg -A 10 "<ServersTab" mcpjam-inspector/client/src/App.tsx

Repository: MCPJam/inspector

Length of output: 479


Update App.tsx to remove the unused isLoadingWorkspaces prop.

The isLoadingWorkspaces prop has been removed from ServersTabProps, but App.tsx still passes it. Remove the isLoadingWorkspaces={isLoadingRemoteWorkspaces} line from the <ServersTab> component in App.tsx to complete this refactor.

🤖 Prompt for AI Agents
In `@mcpjam-inspector/client/src/components/ServersTab.tsx` at line 502, Remove
the now-unused prop from the ServersTab usage in App.tsx: locate the <ServersTab
... /> JSX where isLoadingWorkspaces={isLoadingRemoteWorkspaces} is being passed
and delete that prop entirely so that ServersTab is not given the removed
isLoadingWorkspaces prop; ensure no other references to isLoadingWorkspaces
remain in App.tsx and run TypeScript to verify the prop types align with
ServersTabProps.


{/* Add Server Modal */}
<AddServerModal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,7 @@ export function WorkspaceSelector({
)}
onClick={() => onSwitchWorkspace(workspace.id)}
>
<span className="truncate flex-1">
{workspace.name}
</span>
<span className="truncate flex-1">{workspace.name}</span>
<button
onClick={(e) => {
e.stopPropagation();
Expand Down
40 changes: 40 additions & 0 deletions mcpjam-inspector/client/src/lib/json-config-parser.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ServerFormData } from "@/shared/types.js";
import { ServerWithName } from "@/state/app-types";

export interface JsonServerConfig {
command?: string;
Expand All @@ -12,6 +13,45 @@ export interface JsonConfig {
mcpServers: Record<string, JsonServerConfig>;
}

/**
* Formats ServerWithName objects to JSON config format
* @param serversObj - Record of server names to ServerWithName objects
* @returns JsonConfig object ready for export
*/
export function formatJsonConfig(
serversObj: Record<string, ServerWithName>,
): JsonConfig {
const mcpServers: Record<string, JsonServerConfig> = {};

for (const [key, server] of Object.entries(serversObj)) {
const { config } = server;

// Check if it's an SSE type (has URL) or stdio type (has command)
if ("url" in config && config.url) {
mcpServers[key] = {
type: "sse",
url: config.url.toString(),
};
} else if ("command" in config && config.command) {
const serverConfig: JsonServerConfig = {
command: config.command,
args: config.args || [],
};

// Only add env if it exists and has properties
if (config.env && Object.keys(config.env).length > 0) {
serverConfig.env = config.env;
}

mcpServers[key] = serverConfig;
} else {
console.warn(`Skipping server "${key}": missing required url or command`);
}
}

return { mcpServers };
}

/**
* Parses a JSON config file and converts it to ServerFormData array
* @param jsonContent - The JSON string content
Expand Down