Skip to content

Commit 9f4aadd

Browse files
add server to different project from server card (#2333)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0201ea1 commit 9f4aadd

5 files changed

Lines changed: 446 additions & 90 deletions

File tree

mcpjam-inspector/client/src/components/ServersTab.tsx

Lines changed: 136 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,7 @@ import {
3131

3232
import { JsonImportModal } from "./connection/JsonImportModal";
3333
import { ServerFormData } from "@/shared/types.js";
34-
import {
35-
useAppReady,
36-
useAppReadyMessage,
37-
} from "@/hooks/use-app-ready";
34+
import { useAppReady, useAppReadyMessage } from "@/hooks/use-app-ready";
3835
import { MCPIcon } from "./ui/mcp-icon";
3936
import { usePostHog } from "posthog-js/react";
4037
import {
@@ -89,7 +86,9 @@ import {
8986
import {
9087
useProjectServers as useRemoteProjectServers,
9188
useProjectMembers,
89+
useServerMutations,
9290
shouldQueryProjectId,
91+
type RemoteServer,
9392
} from "@/hooks/useProjects";
9493
import { projectClientCapabilitiesNeedReconnect } from "@/lib/client-config";
9594
import {
@@ -260,10 +259,7 @@ function writePersistedLoggerFocus(input: PersistedLoggerFocus): void {
260259
}
261260

262261
try {
263-
sessionStorage.setItem(
264-
LOGGER_FOCUS_STORAGE_KEY,
265-
JSON.stringify(input)
266-
);
262+
sessionStorage.setItem(LOGGER_FOCUS_STORAGE_KEY, JSON.stringify(input));
267263
} catch {
268264
// ignore
269265
}
@@ -428,6 +424,9 @@ function SortableServerCard({
428424
hostedServerId,
429425
onOpenDetailModal,
430426
projectId,
427+
moveTargets,
428+
onMoveToProject,
429+
isMovingToProject,
431430
}: {
432431
id: string;
433432
dndDisabled: boolean;
@@ -448,6 +447,12 @@ function SortableServerCard({
448447
defaultTab: ServerDetailTab
449448
) => void;
450449
projectId: string;
450+
moveTargets?: Array<{ id: string; name: string; icon?: string }>;
451+
onMoveToProject?: (
452+
serverName: string,
453+
targetProjectId: string
454+
) => void | Promise<void>;
455+
isMovingToProject?: boolean;
451456
}) {
452457
const { listeners, setNodeRef, transform, transition, isDragging } =
453458
useSortable({ id, disabled: dndDisabled });
@@ -479,6 +484,9 @@ function SortableServerCard({
479484
hostedServerId={hostedServerId}
480485
onOpenDetailModal={onOpenDetailModal}
481486
projectId={projectId}
487+
moveTargets={moveTargets}
488+
onMoveToProject={onMoveToProject}
489+
isMovingToProject={isMovingToProject}
482490
/>
483491
);
484492

@@ -536,10 +544,7 @@ interface ServersTabProps {
536544
isLoadingProjects?: boolean;
537545
isAuthHydrating?: boolean;
538546
areServersHydrated?: boolean;
539-
onProjectShared?: (
540-
sharedProjectId: string,
541-
sourceProjectId?: string
542-
) => void;
547+
onProjectShared?: (sharedProjectId: string, sourceProjectId?: string) => void;
543548
onLeaveProject?: () => void;
544549
isRegistryEnabled?: boolean;
545550
onNavigateToRegistry?: () => void;
@@ -584,7 +589,7 @@ export function ServersTab({
584589
const sharedProjectIdForHostScope =
585590
projects[activeProjectId]?.sharedProjectId ?? null;
586591
const [previewedHostId] = usePreviewedHostId(
587-
sharedProjectIdForHostScope ?? activeProjectId ?? null,
592+
sharedProjectIdForHostScope ?? activeProjectId ?? null
588593
);
589594
const { host: previewedHost } = useHost({
590595
isAuthenticated,
@@ -595,6 +600,77 @@ export function ServersTab({
595600
isAuthenticated,
596601
});
597602

603+
// --- Move a server to another project (kebab menu) ---
604+
// Server-side move keeps encrypted secret material private and avoids
605+
// reporting success before the source server is deleted.
606+
const { moveServerToProject } = useServerMutations();
607+
const [movingServerName, setMovingServerName] = useState<string | null>(null);
608+
609+
const remoteServersByName = useMemo(() => {
610+
const map: Record<string, RemoteServer> = {};
611+
for (const s of viewProjectServersList ?? []) map[s.name] = s;
612+
return map;
613+
}, [viewProjectServersList]);
614+
615+
const moveTargets = useMemo(
616+
() =>
617+
Object.values(projects)
618+
.filter((p) => p.id !== activeProjectId && !!p.sharedProjectId)
619+
.filter((p) =>
620+
organizationId
621+
? p.organizationId === organizationId
622+
: !p.organizationId
623+
)
624+
.sort((a, b) => {
625+
if (a.isDefault) return -1;
626+
if (b.isDefault) return 1;
627+
return a.name.localeCompare(b.name);
628+
})
629+
.map((p) => ({ id: p.id, name: p.name, icon: p.icon })),
630+
[projects, activeProjectId, organizationId]
631+
);
632+
633+
const handleMoveServerToProject = useCallback(
634+
async (serverName: string, targetProjectId: string) => {
635+
const remote = remoteServersByName[serverName];
636+
const target = projects[targetProjectId];
637+
const targetSharedId = target?.sharedProjectId;
638+
if (!remote) {
639+
toast.error(`Couldn't find "${serverName}" to move.`);
640+
return;
641+
}
642+
if (!targetSharedId) {
643+
toast.error(`"${target?.name ?? "That project"}" isn't synced yet.`);
644+
return;
645+
}
646+
setMovingServerName(serverName);
647+
try {
648+
await moveServerToProject({
649+
serverId: remote._id,
650+
targetProjectId: targetSharedId,
651+
});
652+
await Promise.resolve(onDisconnect(serverName));
653+
await Promise.resolve(onRemove(serverName));
654+
toast.success(`Moved "${serverName}" to ${target.name}`);
655+
} catch (error) {
656+
// Keep the raw error in the console for us; users get a friendly toast.
657+
console.error(
658+
`Failed to move server "${serverName}" to "${target.name}":`,
659+
error
660+
);
661+
const message = error instanceof Error ? error.message : String(error);
662+
toast.error(
663+
/already exists|already has/i.test(message)
664+
? `"${target.name}" already has a server named "${serverName}". Rename or remove it there first.`
665+
: `Couldn't move "${serverName}" to "${target.name}". Please try again.`
666+
);
667+
} finally {
668+
setMovingServerName(null);
669+
}
670+
},
671+
[remoteServersByName, projects, moveServerToProject, onDisconnect, onRemove]
672+
);
673+
598674
// Project-wide auto-connect toggle. Single switch in the header that
599675
// either enrolls every catalog server in project.serverIds (ON) or
600676
// clears the set (OFF). Overrides on still-included servers are
@@ -621,18 +697,18 @@ export function ServersTab({
621697
"projectServerConfig:getConfig" as any,
622698
sharedProjectIdForHostScope && isAuthenticated
623699
? ({ projectId: sharedProjectIdForHostScope } as any)
624-
: "skip",
700+
: "skip"
625701
) as ProjectServerConfigDto | null | undefined;
626702
const setProjectServerConfigMutation = useMutation(
627-
"projectServerConfig:setConfig" as any,
703+
"projectServerConfig:setConfig" as any
628704
) as unknown as (args: {
629705
projectId: string;
630706
input: ProjectServerConfigInput;
631707
}) => Promise<ProjectServerConfigDto>;
632708
const [isTogglingAutoConnect, setIsTogglingAutoConnect] = useState(false);
633709
const catalogServerIds = useMemo(
634710
() => (viewProjectServersList ?? []).map((s) => s._id),
635-
[viewProjectServersList],
711+
[viewProjectServersList]
636712
);
637713
const autoConnectAll = useMemo(() => {
638714
if (!projectServerConfigDto || catalogServerIds.length === 0) return false;
@@ -656,8 +732,8 @@ export function ServersTab({
656732
const catalogIdSet = new Set(catalogServerIds);
657733
const preservedOverrides = Object.fromEntries(
658734
Object.entries(projectServerConfigDto?.overrides ?? {}).filter(
659-
([id]) => catalogIdSet.has(id),
660-
),
735+
([id]) => catalogIdSet.has(id)
736+
)
661737
);
662738
await setProjectServerConfigMutation({
663739
projectId: sharedProjectIdForHostScope,
@@ -688,7 +764,7 @@ export function ServersTab({
688764
catalogServerIds,
689765
projectServerConfigDto,
690766
setProjectServerConfigMutation,
691-
],
767+
]
692768
);
693769

694770
const renderAutoConnectToggle = () => {
@@ -703,7 +779,7 @@ export function ServersTab({
703779
<label
704780
className={cn(
705781
"flex items-center gap-2 text-xs text-muted-foreground select-none",
706-
disabled ? "cursor-not-allowed" : "cursor-pointer",
782+
disabled ? "cursor-not-allowed" : "cursor-pointer"
707783
)}
708784
title={
709785
canManageProjectServers
@@ -726,7 +802,7 @@ export function ServersTab({
726802
const requiredIds = previewedHost?.config?.serverIds ?? [];
727803
if (requiredIds.length === 0 || !viewProjectServersList) return [];
728804
const byId = new Map(
729-
viewProjectServersList.map((s) => [s._id, s.name] as const),
805+
viewProjectServersList.map((s) => [s._id, s.name] as const)
730806
);
731807
return requiredIds
732808
.map((id) => byId.get(id))
@@ -783,9 +859,7 @@ export function ServersTab({
783859
const persistedLoggerFocus = readPersistedLoggerFocus(activeProjectId);
784860
const [focusedLoggerServerIds, setFocusedLoggerServerIds] = useState<
785861
string[] | undefined
786-
>(
787-
persistedLoggerFocus ? [persistedLoggerFocus.serverName] : undefined
788-
);
862+
>(persistedLoggerFocus ? [persistedLoggerFocus.serverName] : undefined);
789863
const [focusedLoggerSinceTimestamp, setFocusedLoggerSinceTimestamp] =
790864
useState<number | undefined>(persistedLoggerFocus?.sinceTimestamp);
791865
const [detailModalState, setDetailModalState] = useState<{
@@ -803,10 +877,7 @@ export function ServersTab({
803877
});
804878

805879
// --- Self-contained local ordering (localStorage only, never synced to Convex) ---
806-
const allNames = useMemo(
807-
() => Object.keys(projectServers),
808-
[projectServers]
809-
);
880+
const allNames = useMemo(() => Object.keys(projectServers), [projectServers]);
810881

811882
const [orderedServerNames, setOrderedServerNames] = useState<string[]>(() => {
812883
const saved = loadServerOrder(activeProjectId);
@@ -1063,11 +1134,12 @@ export function ServersTab({
10631134
const activeProject = projects[activeProjectId];
10641135
const sharedProjectId = activeProject?.sharedProjectId;
10651136
const hostedProjectId = sharedProjectId ?? activeProjectId;
1066-
const { serversRecord: sharedProjectServersRecord } =
1067-
useRemoteProjectServers({
1137+
const { serversRecord: sharedProjectServersRecord } = useRemoteProjectServers(
1138+
{
10681139
projectId: sharedProjectId ?? null,
10691140
isAuthenticated,
1070-
});
1141+
}
1142+
);
10711143
const detailModalHostedServerId = detailModalServer
10721144
? sharedProjectServersRecord[detailModalServer.name]?._id
10731145
: undefined;
@@ -1169,7 +1241,9 @@ export function ServersTab({
11691241

11701242
const handleJsonImport = (servers: ServerFormData[]) => {
11711243
if (isAppBootstrapping) {
1172-
toast.error(appReadyMessage ?? "App is still loading. Try again in a moment.");
1244+
toast.error(
1245+
appReadyMessage ?? "App is still loading. Try again in a moment."
1246+
);
11731247
return;
11741248
}
11751249
servers.forEach((server) => {
@@ -1181,7 +1255,9 @@ export function ServersTab({
11811255
const handleConnectServer = useCallback(
11821256
(formData: ServerFormData) => {
11831257
if (isAppBootstrapping) {
1184-
toast.error(appReadyMessage ?? "App is still loading. Try again in a moment.");
1258+
toast.error(
1259+
appReadyMessage ?? "App is still loading. Try again in a moment."
1260+
);
11851261
return;
11861262
}
11871263
focusLoggerOnServer(formData.name);
@@ -1196,10 +1272,12 @@ export function ServersTab({
11961272
options?: {
11971273
forceOAuthFlow?: boolean;
11981274
allowInteractiveOAuthFlow?: boolean;
1199-
},
1275+
}
12001276
) => {
12011277
if (isAppBootstrapping) {
1202-
toast.error(appReadyMessage ?? "App is still loading. Try again in a moment.");
1278+
toast.error(
1279+
appReadyMessage ?? "App is still loading. Try again in a moment."
1280+
);
12031281
return;
12041282
}
12051283
focusLoggerOnServer(serverName);
@@ -1221,7 +1299,9 @@ export function ServersTab({
12211299

12221300
const handleQuickConnect = async (server: EnrichedRegistryServer) => {
12231301
if (isAppBootstrapping) {
1224-
toast.error(appReadyMessage ?? "App is still loading. Try again in a moment.");
1302+
toast.error(
1303+
appReadyMessage ?? "App is still loading. Try again in a moment."
1304+
);
12251305
return;
12261306
}
12271307
const serverName = getRegistryServerName(server);
@@ -1537,7 +1617,9 @@ export function ServersTab({
15371617
{/* Header Section */}
15381618
<div className="flex flex-wrap items-center justify-end gap-2">
15391619
<div className="flex items-center gap-2">
1540-
{showServerActionsInHostsHeader ? null : renderAutoConnectToggle()}
1620+
{showServerActionsInHostsHeader
1621+
? null
1622+
: renderAutoConnectToggle()}
15411623
{shouldShowBrowseRegistryOnly && onNavigateToRegistry ? (
15421624
<Button
15431625
variant="outline"
@@ -1550,7 +1632,9 @@ export function ServersTab({
15501632
<ArrowRight className="h-3.5 w-3.5 ml-1" />
15511633
</Button>
15521634
) : null}
1553-
{!showServerActionsInHostsHeader ? renderServerActionsMenu() : null}
1635+
{!showServerActionsInHostsHeader
1636+
? renderServerActionsMenu()
1637+
: null}
15541638
</div>
15551639
</div>
15561640

@@ -1592,6 +1676,9 @@ export function ServersTab({
15921676
hostedServerId={sharedProjectServersRecord[name]?._id}
15931677
onOpenDetailModal={handleOpenDetailModal}
15941678
projectId={activeProjectId}
1679+
moveTargets={moveTargets}
1680+
onMoveToProject={handleMoveServerToProject}
1681+
isMovingToProject={movingServerName === name}
15951682
/>
15961683
);
15971684
})}
@@ -1614,9 +1701,7 @@ export function ServersTab({
16141701
clearPendingQuickConnectIfMatches(serverName);
16151702
onRemove(serverName);
16161703
}}
1617-
hostedServerId={
1618-
sharedProjectServersRecord[activeId!]?._id
1619-
}
1704+
hostedServerId={sharedProjectServersRecord[activeId!]?._id}
16201705
projectId={activeProjectId}
16211706
/>
16221707
</div>
@@ -1756,19 +1841,15 @@ export function ServersTab({
17561841
{isAuthHydrating ||
17571842
isBillingContextPending ||
17581843
isLoadingProjects ||
1759-
!areServersHydrated ? (
1760-
renderLoadingContent()
1761-
) : !selectedProject ? (
1762-
shouldQueryProjectId(activeProjectId) ? (
1763-
renderLoadingContent()
1764-
) : (
1765-
renderNoProjectContent()
1766-
)
1767-
) : hasAnyServers ? (
1768-
renderConnectedContent()
1769-
) : (
1770-
renderEmptyContent()
1771-
)}
1844+
!areServersHydrated
1845+
? renderLoadingContent()
1846+
: !selectedProject
1847+
? shouldQueryProjectId(activeProjectId)
1848+
? renderLoadingContent()
1849+
: renderNoProjectContent()
1850+
: hasAnyServers
1851+
? renderConnectedContent()
1852+
: renderEmptyContent()}
17721853

17731854
{/* Add Server Modal */}
17741855
<AddServerModal
@@ -1825,7 +1906,7 @@ export function ServersTab({
18251906
{renderAutoConnectToggle()}
18261907
{renderServerActionsMenu()}
18271908
</>,
1828-
hostsConnectAddServerSlot,
1909+
hostsConnectAddServerSlot
18291910
)
18301911
: null}
18311912
</div>

0 commit comments

Comments
 (0)