Skip to content
Draft
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
8 changes: 8 additions & 0 deletions mcpjam-inspector/client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { AppBuilderTab } from "./components/ui-playground/AppBuilderTab";
import { ProfileTab } from "./components/ProfileTab";
import { OrganizationsTab } from "./components/OrganizationsTab";
import { SupportTab } from "./components/SupportTab";
import { RegistryTab } from "./components/RegistryTab";
import OAuthDebugCallback from "./components/oauth/OAuthDebugCallback";
import { MCPSidebar } from "./components/mcp-sidebar";
import { SidebarInset, SidebarProvider } from "./components/ui/sidebar";
Expand Down Expand Up @@ -500,6 +501,13 @@ export default function App() {
onLeaveWorkspace={() => handleLeaveWorkspace(activeWorkspaceId)}
/>
)}
{activeTab === "registry" && (
<RegistryTab
onConnect={handleConnect}
onDisconnect={handleDisconnect}
servers={appState.servers}
/>
)}
{activeTab === "tools" && (
<div className="h-full overflow-hidden">
<ToolsTab
Expand Down
204 changes: 204 additions & 0 deletions mcpjam-inspector/client/src/components/RegistryTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { useQuery } from "convex/react";
import { Package, Loader2, CheckCircle2, Unplug } from "lucide-react";
import { Card } from "./ui/card";
import { Skeleton } from "./ui/skeleton";
import { EmptyState } from "./ui/empty-state";
import type { ServerFormData } from "@/shared/types";
import type { ServerWithName } from "@/state/app-types";
import { useState } from "react";

interface RegistryServer {
_id: string;
slug: string;
name: string;
description: string;
iconUrl: string;
url: string;
useOAuth: boolean;
oauthScopes?: string[];
clientId?: string;
sortOrder: number;
}

interface RegistryTabProps {
onConnect: (formData: ServerFormData) => void;
onDisconnect?: (serverName: string) => void;
servers?: Record<string, ServerWithName>;
}

function RegistryServerCard({
server,
onConnect,
onDisconnect,
connectionStatus,
}: {
server: RegistryServer;
onConnect: (formData: ServerFormData) => void;
onDisconnect?: (serverName: string) => void;
connectionStatus?: string;
}) {
const [isConnecting, setIsConnecting] = useState(false);
const isConnected = connectionStatus === "connected";
const isInProgress =
connectionStatus === "connecting" || connectionStatus === "oauth-flow";

const handleConnect = () => {
setIsConnecting(true);
const formData: ServerFormData = {
name: server.name,
type: "http",
url: server.url,
useOAuth: server.useOAuth,
oauthScopes: server.oauthScopes,
clientId: server.clientId,
registryManaged: true,
registrySlug: server.slug,
};
onConnect(formData);
setTimeout(() => setIsConnecting(false), 2000);
};

const handleDisconnect = () => {
onDisconnect?.(server.name);
};

return (
<Card className="group h-full rounded-xl border border-border/50 bg-card/60 p-0 shadow-sm transition-all duration-200 hover:border-border hover:shadow-md">
<div className="p-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<img
src={server.iconUrl}
alt={`${server.name} icon`}
className="h-8 w-8 rounded"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate text-sm font-semibold text-foreground">
{server.name}
</h3>
{isConnected && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="h-3 w-3" />
Connected
</span>
)}
</div>
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-2">
{server.description}
</p>
</div>
</div>

<div className="mt-3 rounded-md border border-border/50 bg-muted/30 p-2 font-mono text-xs text-muted-foreground">
<div className="truncate">{server.url}</div>
</div>

<div className="mt-3 flex items-center justify-end gap-2">
{isConnected ? (
<button
onClick={handleDisconnect}
className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-muted/30 px-3 py-1 text-xs font-medium text-destructive transition-colors hover:bg-destructive/10 cursor-pointer"
>
<Unplug className="h-3 w-3" />
<span>Disconnect</span>
</button>
) : (
<button
onClick={handleConnect}
disabled={isConnecting || isInProgress}
className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-muted/30 px-3 py-1 text-xs font-medium text-foreground transition-colors hover:bg-accent/60 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"
>
{isConnecting || isInProgress ? (
<>
<Loader2 className="h-3 w-3 animate-spin" />
<span>Connecting...</span>
</>
) : (
<span>Connect</span>
)}
</button>
)}
</div>
</div>
</Card>
);
}

function LoadingSkeleton() {
return (
<div className="h-full w-full overflow-auto">
<div className="space-y-6 p-8">
<div>
<Skeleton className="h-6 w-40 mb-2" />
<Skeleton className="h-4 w-72" />
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-36 rounded-xl" />
))}
</div>
</div>
</div>
);
}

export function RegistryTab({
onConnect,
onDisconnect,
servers,
}: RegistryTabProps) {
const registryServers = useQuery(
"registryServers:listEnabled" as any,
{} as any,
) as RegistryServer[] | undefined;

const isLoading = registryServers === undefined;

if (isLoading) {
return <LoadingSkeleton />;
}

if (!registryServers || registryServers.length === 0) {
return (
<EmptyState
icon={Package}
title="No Registry Servers"
description="No servers are available in the registry at this time."
/>
);
}

return (
<div className="h-full w-full overflow-auto">
<div className="space-y-6 p-8">
<div>
<h2 className="text-lg font-semibold text-foreground">
Server Registry
</h2>
<p className="text-sm text-muted-foreground">
Connect to popular MCP servers with one click.
</p>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{registryServers.map((server) => {
const serverState = servers?.[server.name];
return (
<RegistryServerCard
key={server._id}
server={server}
onConnect={onConnect}
onDisconnect={onDisconnect}
connectionStatus={serverState?.connectionStatus}
/>
);
})}
</div>
</div>
</div>
);
}
6 changes: 6 additions & 0 deletions mcpjam-inspector/client/src/components/mcp-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ListTodo,
SquareSlash,
MessageCircleQuestionIcon,
Package,
} from "lucide-react";
import { usePostHog } from "posthog-js/react";

Expand Down Expand Up @@ -54,6 +55,11 @@ const navigationSections = [
url: "#servers",
icon: MCPIcon,
},
{
title: "Registry",
url: "#registry",
icon: Package,
},
{
title: "Chat",
url: "#chat-v2",
Expand Down
85 changes: 47 additions & 38 deletions mcpjam-inspector/client/src/hooks/use-server-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
getStoredTokens,
clearOAuthData,
initiateOAuth,
type MCPOAuthOptions,
} from "@/lib/oauth/mcp-oauth";
import { HOSTED_MODE } from "@/lib/config";
import { injectHostedServerMapping } from "@/lib/apis/web/context";
Expand Down Expand Up @@ -552,43 +553,49 @@ export function useServerState({
retryCount: 0,
enabled: true,
useOAuth: formData.useOAuth ?? false,
registryManaged: formData.registryManaged,
registrySlug: formData.registrySlug,
};
if (HOSTED_MODE) {
try {
const serverId = await syncServerToConvex(
formData.name,
serverEntryForSave,
);
if (serverId) {
injectHostedServerMapping(formData.name, serverId);

// Skip Convex workspace sync and workspace state for registry-managed servers
if (!formData.registryManaged) {
if (HOSTED_MODE) {
try {
const serverId = await syncServerToConvex(
formData.name,
serverEntryForSave,
);
if (serverId) {
injectHostedServerMapping(formData.name, serverId);
}
} catch (err) {
logger.warn("Sync to Convex failed (pre-connection)", {
serverName: formData.name,
err,
});
}
} catch (err) {
logger.warn("Sync to Convex failed (pre-connection)", {
serverName: formData.name,
err,
});
} else {
syncServerToConvex(formData.name, serverEntryForSave).catch((err) =>
logger.warn("Background sync to Convex failed (pre-connection)", {
serverName: formData.name,
err,
}),
);
}
} else {
syncServerToConvex(formData.name, serverEntryForSave).catch((err) =>
logger.warn("Background sync to Convex failed (pre-connection)", {
serverName: formData.name,
err,
}),
);
}
if (!isAuthenticated) {
const workspace = appState.workspaces[appState.activeWorkspaceId];
if (workspace) {
dispatch({
type: "UPDATE_WORKSPACE",
workspaceId: appState.activeWorkspaceId,
updates: {
servers: {
...workspace.servers,
[formData.name]: serverEntryForSave,
if (!isAuthenticated) {
const workspace = appState.workspaces[appState.activeWorkspaceId];
if (workspace) {
dispatch({
type: "UPDATE_WORKSPACE",
workspaceId: appState.activeWorkspaceId,
updates: {
servers: {
...workspace.servers,
[formData.name]: serverEntryForSave,
},
},
},
});
});
}
}
}

Expand Down Expand Up @@ -654,15 +661,17 @@ export function useServerState({
} as ServerWithName,
});

const oauthOptions: any = {
const oauthOptions: MCPOAuthOptions = {
serverName: formData.name,
serverUrl: formData.url,
clientId: formData.clientId,
clientSecret: formData.clientSecret,
// For registry servers, clientSecret is injected server-side
clientSecret: formData.registryManaged
? undefined
: formData.clientSecret,
registrySlug: formData.registrySlug,
scopes: formData.oauthScopes,
};
if (formData.oauthScopes && formData.oauthScopes.length > 0) {
oauthOptions.scopes = formData.oauthScopes;
}
const oauthResult = await initiateOAuth(oauthOptions);
if (oauthResult.success) {
if (oauthResult.serverConfig) {
Expand Down
2 changes: 1 addition & 1 deletion mcpjam-inspector/client/src/lib/hosted-tab-policy.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
const HASH_TAB_ALIASES = {
registry: "servers",
chat: "chat-v2",
} as const;

export const HOSTED_SIDEBAR_ALLOWED_TABS = [
"servers",
"registry",
"chat-v2",
"app-builder",
"views",
Expand Down
Loading
Loading