Skip to content

Commit 5e7ad54

Browse files
gabrypavanelloSirius
andauthored
Server Persistence Across Sessions (#29) (#161)
* feat(persistence): add ServerStore class (TASK-029-01) XDG-compliant server config persistence at ~/.config/mcp-inspector/servers.json. Modeled after TokenStore: atomic writes, 0o600 file permissions, 0o700 directory. Stores: id, name, url, transport, params, hasOAuth flag, addedAt timestamp. Methods: load(), save(), delete(), listAll(), migrate(), isEmpty(), clear(), exists(). Migration support for importing localStorage data during frontend migration. 32 tests cover CRUD, migration, permissions, corruption handling, atomic writes. * feat(inspector): add ephemeral flag to ConnectOptions [TASK-029-02] * feat(persistence): wire ServerStore to ConnectionRegistry (TASK-029-03) * feat(inspector): frontend migration and API integration (TASK-029-06) * feat(inspector): add remove button for persisted servers (TASK-029-07) - Add confirmation dialog before server removal (DELETE /api/servers/:id) - Remove button (trash icon) is separate from Stop button - Stopped servers show both Start and Remove actions - Active servers show both Stop and Remove actions - onDeleteServer callback distinguishes connected vs stopped servers * feat(inspector): wire ServerStore API routes to HTTP servers (TASK-029) Add /api/servers routes to both dual-server and standalone-server: - GET /api/servers — list persisted servers - POST /api/servers — save a server entry - POST /api/servers/migrate — migrate from localStorage - DELETE /api/servers/:id — remove a persisted server Wire ServerStore instance into ConnectionRegistry constructor. This connects the persistence layer (029-01/03) to the HTTP layer that the frontend (029-06) calls. * fix(inspector): resolve lint errors in server persistence routes - Type-assert JSON.parse outputs for migrate and save endpoints - Replace dynamic delete with destructuring in ServerStore - Remove unnecessary Boolean() wrapper on hasOAuth - Improve confirmation dialog message (remove internal detail) - Remove unused err catch parameters * fix(inspector): validate server entries and default hasOAuth in migration - Add ServerStore.validateEntry() static method for runtime validation - POST /api/servers now validates payload before saving (rejects malformed) - Migration defaults hasOAuth to false when undefined - Remove unused PersistedServerEntry imports from server files --------- Co-authored-by: Sirius <sirius@clawd.bot>
1 parent 446b9a9 commit 5e7ad54

File tree

9 files changed

+1543
-55
lines changed

9 files changed

+1543
-55
lines changed

packages/inspector/src/connection-registry.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ import { ConnectionManager } from "./connection";
1111
import type { ConnectOptions, ConnectionStatusOutput, InspectorServerOptions } from "./types";
1212
import type { OAuthState } from "./oauth/types";
1313
import type { AuthRequiredEvent } from "./oauth/discovery";
14+
import type {
15+
ServerStore,
16+
PersistedServerEntry,
17+
ServerTransport,
18+
} from "./persistence/server-store";
1419

1520
/**
1621
* Event map emitted by the connection registry.
@@ -34,6 +39,8 @@ export interface ConnectionRegistryOptions {
3439
maxConnections?: number;
3540
/** Base options for each ConnectionManager instance. */
3641
connectionManagerOptions?: InspectorServerOptions;
42+
/** Optional server store for persisting server configurations. */
43+
serverStore?: ServerStore;
3744
}
3845

3946
/**
@@ -53,6 +60,7 @@ export class ConnectionRegistry extends EventEmitter {
5360
private activeConnectionId: string | null = null;
5461
private readonly maxConnections: number;
5562
private readonly connectionManagerOptions: InspectorServerOptions;
63+
private readonly serverStore: ServerStore | null;
5664

5765
/**
5866
* Create a ConnectionRegistry instance.
@@ -63,6 +71,7 @@ export class ConnectionRegistry extends EventEmitter {
6371
super();
6472
this.maxConnections = options.maxConnections ?? 20;
6573
this.connectionManagerOptions = options.connectionManagerOptions ?? {};
74+
this.serverStore = options.serverStore ?? null;
6675
}
6776

6877
/**
@@ -110,6 +119,33 @@ export class ConnectionRegistry extends EventEmitter {
110119
}
111120
this.setActive(id);
112121

122+
// Persist server if not ephemeral, store is available, and serverInfo exists
123+
if (this.serverStore && !options?.ephemeral) {
124+
const state = connectionManager.getState();
125+
if (state.serverInfo) {
126+
const transport: ServerTransport = params.transport === "stdio" ? "stdio" : "http";
127+
const url =
128+
params.transport === "http"
129+
? params.url
130+
: `${params.command}${params.args?.length ? " " + params.args.join(" ") : ""}`;
131+
132+
const entry: PersistedServerEntry = {
133+
id,
134+
name: state.serverInfo.name,
135+
url,
136+
transport,
137+
params,
138+
hasOAuth: false,
139+
addedAt: Date.now(),
140+
};
141+
142+
// Fire-and-forget — don't block connection on persistence
143+
this.serverStore.save(entry).catch(() => {
144+
// Best-effort persistence
145+
});
146+
}
147+
}
148+
113149
return { id, connectionManager };
114150
}
115151

@@ -243,6 +279,21 @@ export class ConnectionRegistry extends EventEmitter {
243279
return this.getConnection(connectionId).getDiscoveryResults();
244280
}
245281

282+
/**
283+
* Delete a server from persistent storage.
284+
*
285+
* Does NOT disconnect any active connection — only removes persisted data.
286+
*
287+
* @param id - Server ID to remove from storage.
288+
* @returns true if the server was deleted, false if not found or no store configured.
289+
*/
290+
async deleteServer(id: string): Promise<boolean> {
291+
if (!this.serverStore) {
292+
return false;
293+
}
294+
return this.serverStore.delete(id);
295+
}
296+
246297
/**
247298
* Close all active connections.
248299
*/

packages/inspector/src/dashboard/react/InspectorDashboard.tsx

Lines changed: 145 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import { useConnections } from "./hooks/useConnections";
1818
import { useOAuth } from "./hooks/useOAuth";
1919
import { useMcpPrimitives, type McpPrimitives } from "./hooks/useMcpPrimitives";
2020
import type { ConnectionParams } from "@mcp-apps-kit/testing";
21-
import { z } from "zod";
2221
import { Toolbar } from "./components/Toolbar";
2322
import { GlobalsPanel } from "./components/GlobalsPanel";
2423
import {
@@ -36,63 +35,121 @@ import { styles } from "./styles";
3635
import logoUrl from "../assets/logo.png";
3736

3837
// =============================================================================
39-
// Stopped Connections Storage
38+
// Server Persistence API
4039
// =============================================================================
4140

4241
const STOPPED_CONNECTIONS_KEY = "mcp-dashboard-stopped-connections";
4342

44-
/** Zod schema for ConnectionParams validation */
45-
const ConnectionParamsSchema = z.union([
46-
z.object({
47-
transport: z.literal("http"),
48-
url: z.string().min(1),
49-
}),
50-
z.object({
51-
transport: z.literal("stdio"),
52-
command: z.string().min(1),
53-
args: z.array(z.string()).optional(),
54-
env: z.record(z.string(), z.string()).optional(),
55-
inheritEnv: z.boolean().optional(),
56-
cwd: z.string().optional(),
57-
}),
58-
]);
59-
60-
/** Zod schema for StoppedConnection validation */
61-
const StoppedConnectionSchema = z.object({
62-
id: z.string().min(1),
63-
name: z.string().min(1),
64-
url: z.string().min(1),
65-
params: ConnectionParamsSchema,
66-
});
67-
68-
/** Load stopped connections from localStorage */
69-
function loadStoppedConnections(): StoppedConnection[] {
43+
/** Shape of a persisted server entry from the backend API */
44+
interface PersistedServerEntry {
45+
id: string;
46+
name: string;
47+
url: string;
48+
transport: "http" | "stdio";
49+
params: ConnectionParams;
50+
hasOAuth: boolean;
51+
addedAt: number;
52+
}
53+
54+
/** API response wrapper */
55+
interface ApiResponse<T = undefined> {
56+
success: boolean;
57+
error?: string;
58+
servers?: PersistedServerEntry[];
59+
imported?: number;
60+
data?: T;
61+
}
62+
63+
/** Fetch persisted servers from backend */
64+
async function fetchPersistedServers(): Promise<PersistedServerEntry[]> {
65+
try {
66+
const res = await fetch("/api/servers");
67+
const data = (await res.json()) as ApiResponse;
68+
if (data.success && data.servers) {
69+
return data.servers;
70+
}
71+
return [];
72+
} catch {
73+
return [];
74+
}
75+
}
76+
77+
/** Save a server to backend persistence */
78+
async function persistServer(entry: PersistedServerEntry): Promise<boolean> {
79+
try {
80+
const res = await fetch("/api/servers", {
81+
method: "POST",
82+
headers: { "Content-Type": "application/json" },
83+
body: JSON.stringify(entry),
84+
});
85+
const data = (await res.json()) as ApiResponse;
86+
return data.success;
87+
} catch {
88+
return false;
89+
}
90+
}
91+
92+
/** Delete a server from backend persistence */
93+
async function deletePersistedServer(id: string): Promise<boolean> {
94+
try {
95+
const res = await fetch(`/api/servers/${encodeURIComponent(id)}`, {
96+
method: "DELETE",
97+
});
98+
const data = (await res.json()) as ApiResponse;
99+
return data.success;
100+
} catch {
101+
return false;
102+
}
103+
}
104+
105+
/** Migrate localStorage data to backend */
106+
async function migrateFromLocalStorage(servers: PersistedServerEntry[]): Promise<boolean> {
107+
try {
108+
const res = await fetch("/api/servers/migrate", {
109+
method: "POST",
110+
headers: { "Content-Type": "application/json" },
111+
body: JSON.stringify({ servers }),
112+
});
113+
const data = (await res.json()) as ApiResponse;
114+
return data.success;
115+
} catch {
116+
return false;
117+
}
118+
}
119+
120+
/** Load stopped connections from localStorage (for migration check only) */
121+
function loadStoppedConnectionsFromLocalStorage(): StoppedConnection[] {
70122
if (typeof window === "undefined") return [];
71123
try {
72124
const stored = localStorage.getItem(STOPPED_CONNECTIONS_KEY);
73125
if (!stored) return [];
74126
const parsed = JSON.parse(stored) as unknown;
75-
// Validate shape
76127
if (!Array.isArray(parsed)) return [];
77-
return parsed
78-
.map((item) => {
79-
const result = StoppedConnectionSchema.safeParse(item);
80-
return result.success ? result.data : null;
81-
})
82-
.filter((item): item is StoppedConnection => item !== null);
128+
// Basic shape validation — entries need id, name, url, params
129+
return (parsed as StoppedConnection[]).filter(
130+
(item) =>
131+
item &&
132+
typeof item.id === "string" &&
133+
typeof item.name === "string" &&
134+
typeof item.url === "string" &&
135+
item.params
136+
);
83137
} catch {
84138
return [];
85139
}
86140
}
87141

88-
/** Save stopped connections to localStorage */
89-
function saveStoppedConnections(connections: StoppedConnection[]): void {
90-
if (typeof window === "undefined") return;
91-
try {
92-
localStorage.setItem(STOPPED_CONNECTIONS_KEY, JSON.stringify(connections));
93-
} catch {
94-
// Ignore storage errors
95-
}
142+
/** Convert a StoppedConnection to a PersistedServerEntry */
143+
function toPersistedEntry(conn: StoppedConnection): PersistedServerEntry {
144+
return {
145+
id: conn.id,
146+
name: conn.name,
147+
url: conn.url,
148+
transport: conn.params.transport === "stdio" ? "stdio" : "http",
149+
params: conn.params,
150+
hasOAuth: false,
151+
addedAt: Date.now(),
152+
};
96153
}
97154

98155
export interface InspectorDashboardProps {
@@ -128,10 +185,44 @@ export function InspectorDashboard({ baseUrl = "" }: InspectorDashboardProps): R
128185
const connectionCacheRef = useRef<Map<string, CachedConnectionState>>(new Map());
129186
const prevConnectionIdRef = useRef<string | null>(null);
130187

131-
// Stopped connections state (persisted to localStorage)
132-
const [stoppedConnections, setStoppedConnections] = useState<StoppedConnection[]>(() =>
133-
loadStoppedConnections()
134-
);
188+
// Stopped connections state (loaded from backend API on mount)
189+
const [stoppedConnections, setStoppedConnections] = useState<StoppedConnection[]>([]);
190+
191+
// Load persisted servers from backend on mount, with localStorage migration
192+
useEffect(() => {
193+
let cancelled = false;
194+
void (async () => {
195+
const persisted = await fetchPersistedServers();
196+
if (cancelled) return;
197+
198+
if (persisted.length > 0) {
199+
// Backend has data — use it
200+
setStoppedConnections(
201+
persisted.map((entry) => ({
202+
id: entry.id,
203+
name: entry.name,
204+
url: entry.url,
205+
params: entry.params,
206+
}))
207+
);
208+
} else {
209+
// Backend empty — check localStorage for migration
210+
const localData = loadStoppedConnectionsFromLocalStorage();
211+
if (localData.length > 0) {
212+
const migrationEntries = localData.map(toPersistedEntry);
213+
const success = await migrateFromLocalStorage(migrationEntries);
214+
if (cancelled) return;
215+
if (success) {
216+
localStorage.removeItem(STOPPED_CONNECTIONS_KEY);
217+
setStoppedConnections(localData);
218+
}
219+
}
220+
}
221+
})();
222+
return () => {
223+
cancelled = true;
224+
};
225+
}, []);
135226

136227
// Track which server is currently reconnecting (shows loading state)
137228
const [reconnectingServerId, setReconnectingServerId] = useState<string | null>(null);
@@ -293,11 +384,6 @@ export function InspectorDashboard({ baseUrl = "" }: InspectorDashboardProps): R
293384
}
294385
}, [activeConnectionId, tools, resources, prompts]);
295386

296-
// Persist stopped connections to localStorage
297-
useEffect(() => {
298-
saveStoppedConnections(stoppedConnections);
299-
}, [stoppedConnections]);
300-
301387
// Build ServerData array from active connections + primitives cache
302388
const serverDataList: ServerData[] = useMemo(() => {
303389
return connections
@@ -621,6 +707,9 @@ export function InspectorDashboard({ baseUrl = "" }: InspectorDashboardProps): R
621707
params,
622708
};
623709

710+
// Persist to backend
711+
await persistServer(toPersistedEntry(stoppedConn));
712+
624713
setStoppedConnections((prev) => {
625714
// Don't duplicate
626715
if (prev.some((s) => s.id === serverId)) return prev;
@@ -643,7 +732,8 @@ export function InspectorDashboard({ baseUrl = "" }: InspectorDashboardProps): R
643732
// Reconnect using stored params
644733
const success = await handleCreateConnection(stoppedConn.params);
645734
if (success) {
646-
// Only remove from stopped list after successful connection
735+
// Remove from backend persistence and local state
736+
await deletePersistedServer(stoppedConn.id);
647737
setStoppedConnections((prev) => prev.filter((s) => s.id !== stoppedConn.id));
648738
}
649739
} finally {
@@ -660,7 +750,8 @@ export function InspectorDashboard({ baseUrl = "" }: InspectorDashboardProps): R
660750
// Close the connection and don't add to stopped list
661751
await closeConnection(serverId);
662752
}
663-
// Remove from stopped list if present
753+
// Remove from backend persistence and local state
754+
await deletePersistedServer(serverId);
664755
setStoppedConnections((prev) => prev.filter((s) => s.id !== serverId));
665756
},
666757
[closeConnection]

packages/inspector/src/dashboard/react/components/McpPrimitivesPanel.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1151,7 +1151,13 @@ function ServerBlock({
11511151
style={localStyles.deleteButton}
11521152
onClick={(e) => {
11531153
e.stopPropagation();
1154-
onDelete();
1154+
if (
1155+
window.confirm(
1156+
`Remove server "${server.name || server.url}"? This action cannot be undone.`
1157+
)
1158+
) {
1159+
onDelete();
1160+
}
11551161
}}
11561162
onMouseEnter={(e) => {
11571163
e.currentTarget.style.color = "#ef4444";

0 commit comments

Comments
 (0)