Skip to content
Open
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
5 changes: 4 additions & 1 deletion mcpjam-inspector/client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -833,7 +833,6 @@ export default function App() {
<MCPSidebar
onNavigate={handleNavigate}
activeTab={activeTab}
servers={workspaceServers}
workspaces={workspaces}
activeWorkspaceId={activeWorkspaceId}
onSwitchWorkspace={handleSidebarSwitchWorkspace}
Expand Down Expand Up @@ -883,6 +882,10 @@ export default function App() {
onReconnect={handleReconnect}
onUpdate={handleUpdate}
onRemove={handleRemoveServer}
onOpenAppBuilder={(serverName) => {
setSelectedServer(serverName);
handleNavigate("app-builder");
}}
workspaces={workspaces}
activeWorkspaceId={activeWorkspaceId}
isLoadingWorkspaces={isLoadingRemoteWorkspaces}
Expand Down
33 changes: 33 additions & 0 deletions mcpjam-inspector/client/src/components/ServersTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Card } from "./ui/card";
import { Button } from "./ui/button";
import { Plus, FileText } from "lucide-react";
import { ServerWithName, type ServerUpdateResult } from "@/hooks/use-app-state";
import { useUiAppServers } from "@/hooks/use-ui-app-servers";
import { ServerConnectionCard } from "./connection/ServerConnectionCard";
import { AddServerModal } from "./connection/AddServerModal";
import {
Expand Down Expand Up @@ -86,6 +87,8 @@ function SortableServerCard({
onDisconnect,
onReconnect,
onRemove,
onOpenAppBuilder,
isUiCapabilityResolved,
hostedServerId,
onOpenDetailModal,
}: {
Expand All @@ -99,6 +102,8 @@ function SortableServerCard({
opts?: { forceOAuthFlow?: boolean },
) => Promise<void>;
onRemove: (name: string) => void;
onOpenAppBuilder?: (serverName: string) => void;
isUiCapabilityResolved?: boolean;
hostedServerId?: string;
onOpenDetailModal?: (
server: ServerWithName,
Expand Down Expand Up @@ -127,6 +132,8 @@ function SortableServerCard({
onDisconnect={onDisconnect}
onReconnect={onReconnect}
onRemove={onRemove}
onOpenAppBuilder={onOpenAppBuilder}
isUiCapabilityResolved={isUiCapabilityResolved}
hostedServerId={hostedServerId}
onOpenDetailModal={onOpenDetailModal}
/>
Expand All @@ -148,6 +155,7 @@ interface ServersTabProps {
skipAutoConnect?: boolean,
) => Promise<ServerUpdateResult>;
onRemove: (serverName: string) => void;
onOpenAppBuilder?: (serverName: string) => void;
workspaces: Record<string, Workspace>;
activeWorkspaceId: string;
isLoadingWorkspaces?: boolean;
Expand All @@ -160,6 +168,7 @@ export function ServersTab({
onReconnect,
onUpdate,
onRemove,
onOpenAppBuilder,
workspaces,
activeWorkspaceId,
isLoadingWorkspaces,
Expand All @@ -185,6 +194,16 @@ export function ServersTab({
sessionKey: 0,
serverSnapshot: null,
});
const { appServerNames, resolvedServerNames } =
useUiAppServers(workspaceServers);
const appServerNameSet = useMemo(
() => new Set(appServerNames),
[appServerNames],
);
const resolvedServerNameSet = useMemo(
() => new Set(resolvedServerNames),
[resolvedServerNames],
);

// --- Self-contained local ordering (localStorage only, never synced to Convex) ---
const allNames = useMemo(
Expand Down Expand Up @@ -510,6 +529,12 @@ export function ServersTab({
onDisconnect={onDisconnect}
onReconnect={onReconnect}
onRemove={onRemove}
onOpenAppBuilder={
appServerNameSet.has(name)
? onOpenAppBuilder
: undefined
}
isUiCapabilityResolved={resolvedServerNameSet.has(name)}
hostedServerId={sharedWorkspaceServersRecord[name]?._id}
onOpenDetailModal={handleOpenDetailModal}
/>
Expand All @@ -528,6 +553,14 @@ export function ServersTab({
onDisconnect={onDisconnect}
onReconnect={onReconnect}
onRemove={onRemove}
onOpenAppBuilder={
appServerNameSet.has(activeServer.name)
? onOpenAppBuilder
: undefined
}
isUiCapabilityResolved={resolvedServerNameSet.has(
activeServer.name,
)}
hostedServerId={
sharedWorkspaceServersRecord[activeId!]?._id
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
Edit,
ExternalLink,
Cable,
Rocket,
Trash2,
AlertCircle,
Share2,
Expand Down Expand Up @@ -75,6 +76,8 @@ interface ServerConnectionCardProps {
onRemove?: (serverName: string) => void;
serverTunnelUrl?: string | null;
hostedServerId?: string;
onOpenAppBuilder?: (serverName: string) => void;
isUiCapabilityResolved?: boolean;
onOpenDetailModal?: (
server: ServerWithName,
defaultTab: ServerDetailTab,
Expand All @@ -89,6 +92,8 @@ export function ServerConnectionCard({
onRemove,
serverTunnelUrl,
hostedServerId,
onOpenAppBuilder,
isUiCapabilityResolved = true,
onOpenDetailModal,
}: ServerConnectionCardProps) {
const posthog = usePostHog();
Expand Down Expand Up @@ -121,8 +126,11 @@ export function ServerConnectionCard({
const isConnected = server.connectionStatus === "connected";
const isTunnelEnabled = !HOSTED_MODE;
const canManageTunnels = isAuthenticated;
const showTunnelActions = isConnected && isTunnelEnabled;
const showTunnelActions =
isConnected && isUiCapabilityResolved && isTunnelEnabled;
const hasTunnel = Boolean(tunnelUrl);
const showAppBuilderAction =
isConnected && isUiCapabilityResolved && onOpenAppBuilder != null;
const hasError =
server.connectionStatus === "failed" && Boolean(server.lastError);
const isHostedHttpReconnectBlocked = isHostedInsecureHttpServer(server);
Expand Down Expand Up @@ -589,6 +597,15 @@ export function ServerConnectionCard({
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
{showAppBuilderAction && (
<button
onClick={() => onOpenAppBuilder(server.name)}
className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-muted/30 px-2 py-0.5 text-[11px] text-foreground transition-colors hover:bg-accent/60 cursor-pointer"
>
<Rocket className="h-3 w-3" />
<span>Test in app builder</span>
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
<span>Test in app builder</span>
<span>Test in App Builder</span>

</button>
)}
{canShareServer && (
<button
onClick={() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -388,5 +388,49 @@ describe("ServerConnectionCard", () => {

expect(screen.queryByText("Copy ngrok URL")).not.toBeInTheDocument();
});

it("hides app-builder and ngrok actions while UI capability is still resolving", () => {
const server = createServer({ connectionStatus: "connected" });
render(
<ServerConnectionCard
server={server}
{...defaultProps}
onOpenAppBuilder={vi.fn()}
isUiCapabilityResolved={false}
/>,
);

expect(screen.queryByText("Test in app builder")).not.toBeInTheDocument();
expect(screen.queryByText("Create ngrok tunnel")).not.toBeInTheDocument();
});

it("shows only ngrok when UI capability is resolved but no UI exists", () => {
const server = createServer({ connectionStatus: "connected" });
render(
<ServerConnectionCard
server={server}
{...defaultProps}
isUiCapabilityResolved={true}
/>,
);

expect(screen.queryByText("Test in app builder")).not.toBeInTheDocument();
expect(screen.getByText("Create ngrok tunnel")).toBeInTheDocument();
});

it("shows app-builder and ngrok together once UI capability is resolved", () => {
const server = createServer({ connectionStatus: "connected" });
render(
<ServerConnectionCard
server={server}
{...defaultProps}
onOpenAppBuilder={vi.fn()}
isUiCapabilityResolved={true}
/>,
);

expect(screen.getByText("Test in app builder")).toBeInTheDocument();
expect(screen.getByText("Create ngrok tunnel")).toBeInTheDocument();
});
});
});
94 changes: 1 addition & 93 deletions mcpjam-inspector/client/src/components/mcp-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from "react";
import { useState, useEffect, useMemo } from "react";
import { useMemo } from "react";
import {
Hammer,
MessageCircle,
Expand Down Expand Up @@ -34,22 +34,12 @@ import { SidebarWorkspaceSelector } from "@/components/sidebar/sidebar-workspace
import { useUpdateNotification } from "@/hooks/useUpdateNotification";
import { Button } from "@/components/ui/button";
import { HOSTED_MODE } from "@/lib/config";
import {
listTools,
type ListToolsResultWithMetadata,
} from "@/lib/apis/mcp-tools-api";
import {
isMCPApp,
isOpenAIApp,
isOpenAIAppAndMCPApp,
} from "@/lib/mcp-ui/mcp-apps-utils";
import {
isHostedSidebarTabAllowed,
normalizeHostedHashTab,
} from "@/lib/hosted-tab-policy";
import { HOSTED_LOCAL_ONLY_TOOLTIP } from "@/lib/hosted-ui";
import type { BillingFeatureName } from "@/hooks/useOrganizationBilling";
import type { ServerWithName } from "@/hooks/use-app-state";
import type { Workspace } from "@/state/app-types";

interface NavItem {
Expand Down Expand Up @@ -277,8 +267,6 @@ const hostedNavigationSections =
interface MCPSidebarProps extends React.ComponentProps<typeof Sidebar> {
onNavigate?: (section: string) => void;
activeTab?: string;
/** Servers to check for app capabilities */
servers?: Record<string, ServerWithName>;
/** Workspace state for the sidebar workspace picker */
workspaces: Record<string, Workspace>;
activeWorkspaceId: string;
Expand All @@ -291,12 +279,9 @@ interface MCPSidebarProps extends React.ComponentProps<typeof Sidebar> {
billingEnforcementActive?: boolean;
}

const APP_BUILDER_VISITED_KEY = "mcp-app-builder-visited";

export function MCPSidebar({
onNavigate,
activeTab,
servers = {},
workspaces,
activeWorkspaceId,
onSwitchWorkspace,
Expand All @@ -317,71 +302,10 @@ export function MCPSidebar({
const learningEnabled = !!learningFlagEnabled && isAuthenticated;
const themeMode = usePreferencesStore((s) => s.themeMode);
const { updateReady, restartAndInstall } = useUpdateNotification();
const [toolsDataMap, setToolsDataMap] = useState<
Record<string, ListToolsResultWithMetadata | null>
>({});
const [hasVisitedAppBuilder, setHasVisitedAppBuilder] = useState(() => {
return localStorage.getItem(APP_BUILDER_VISITED_KEY) === "true";
});

// Get list of connected server names
const connectedServerNames = useMemo(() => {
return Object.entries(servers)
.filter(([, server]) => server.connectionStatus === "connected")
.map(([name]) => name);
}, [servers]);

// Fetch tools data for connected servers
useEffect(() => {
const fetchToolsData = async () => {
if (connectedServerNames.length === 0) {
setToolsDataMap({});
return;
}

const newToolsDataMap: Record<
string,
ListToolsResultWithMetadata | null
> = {};

await Promise.all(
connectedServerNames.map(async (serverName) => {
try {
const result = await listTools({ serverId: serverName });
newToolsDataMap[serverName] = result;
} catch {
newToolsDataMap[serverName] = null;
}
}),
);

setToolsDataMap(newToolsDataMap);
};

fetchToolsData();
}, [connectedServerNames.join(",")]);

// Check if any connected server is an app
const hasAppServer = useMemo(() => {
return Object.values(toolsDataMap).some(
(toolsData) =>
isMCPApp(toolsData) ||
isOpenAIApp(toolsData) ||
isOpenAIAppAndMCPApp(toolsData),
);
}, [toolsDataMap]);

const showAppBuilderBubble =
hasAppServer && activeTab !== "app-builder" && !hasVisitedAppBuilder;

const handleNavClick = (url: string) => {
if (onNavigate && url.startsWith("#")) {
const section = url.slice(1);
// Mark App Builder as visited when clicked (always, not just when bubble is visible)
if (section === "app-builder" && showAppBuilderBubble) {
localStorage.setItem(APP_BUILDER_VISITED_KEY, "true");
setHasVisitedAppBuilder(true);
}
// Track skills tab opened
if (section === "skills") {
posthog.capture("skills_tab_opened");
Expand All @@ -391,19 +315,6 @@ export function MCPSidebar({
window.open(url, "_blank");
}
};

const dismissAppBuilderBubble = () => {
localStorage.setItem(APP_BUILDER_VISITED_KEY, "true");
setHasVisitedAppBuilder(true);
};

const appBuilderBubble = showAppBuilderBubble
? {
message: "Build your UI app with App Builder.",
subMessage: "Get started",
onDismiss: dismissAppBuilderBubble,
}
: null;
const featureFlags = useMemo(
() => ({
"ci-evals-enabled": !!ciEvalsEnabled && isAuthenticated,
Expand Down Expand Up @@ -473,9 +384,6 @@ export function MCPSidebar({
isActive: item.url === `#${activeTab}`,
}))}
onItemClick={handleNavClick}
appBuilderBubble={
section.id === "mcp-apps" ? appBuilderBubble : null
}
/>
{/* Add subtle divider between sections (except after the last section) */}
{sectionIndex < visibleNavigationSections.length - 1 && (
Expand Down
Loading
Loading