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
29 changes: 29 additions & 0 deletions clients/web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions clients/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@modelcontextprotocol/sdk": "^1.29.0",
"ajv": "^8.17.1",
"atomically": "^2.1.1",
"chokidar": "^4.0.3",
"hono": "^4.12.18",
"open": "^10.2.0",
"pino": "^9.14.0",
Expand Down
3 changes: 2 additions & 1 deletion clients/web/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export async function startHonoServer(

const rootPath = config.staticRoot ?? __dirname;

const { app: apiApp } = createRemoteApp({
const { app: apiApp, close: closeApi } = createRemoteApp({
authToken: config.dangerouslyOmitAuth ? undefined : resolvedAuthToken,
dangerouslyOmitAuth: config.dangerouslyOmitAuth,
storageDir: config.storageDir,
Expand Down Expand Up @@ -117,6 +117,7 @@ export async function startHonoServer(

return {
async close(): Promise<void> {
await closeApi();
await sandboxController.close();
if ("closeAllConnections" in httpServer) {
httpServer.closeAllConnections();
Expand Down
5 changes: 5 additions & 0 deletions clients/web/server/vite-base-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export function getViteBaseConfig() {
// keeps Vite's dev-time scanner from chasing it through the plugin's
// node-only import chain.
"atomically",
// `chokidar` is only loaded inside `core/mcp/remote/node/server.ts`
// when the lazy mcp.json watcher starts. It transitively imports
// `readdirp` and core node fs/os modules; excluding it keeps Vite's
// dep scanner from walking into them during dev startup.
"chokidar",
"cross-spawn",
"which",
],
Expand Down
21 changes: 14 additions & 7 deletions clients/web/server/vite-hono-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,11 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
});
await sandboxController.start();

const originalClose = server.close.bind(server);
server.close = async () => {
await sandboxController.close();
return originalClose();
};

const { app: honoApp, authToken: resolvedToken } = createRemoteApp({
const {
app: honoApp,
authToken: resolvedToken,
close: closeApi,
} = createRemoteApp({
authToken: config.dangerouslyOmitAuth ? undefined : config.authToken,
dangerouslyOmitAuth: config.dangerouslyOmitAuth,
storageDir: config.storageDir,
Expand All @@ -69,6 +67,15 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
initialConfig: webServerConfigToInitialPayload(config),
});

// Chain the API close (mcp.json watcher) and the sandbox into the
// Vite server's close so dev-server restarts release both resources.
const originalClose = server.close.bind(server);
server.close = async () => {
await closeApi();
await sandboxController.close();
return originalClose();
};

const sandboxUrl = sandboxController.getUrl();

const logBanner = () => {
Expand Down
94 changes: 89 additions & 5 deletions clients/web/src/test/core/react/useServers.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,13 @@ interface Harness {
fetchFn: typeof fetch;
configPath: string;
tempDir: string;
closeApi: () => Promise<void>;
}

function setupHarness(): Harness {
const tempDir = mkdtempSync(join(tmpdir(), "inspector-useServers-"));
const configPath = join(tempDir, "mcp.json");
const { app } = createRemoteApp({
const { app, close: closeApi } = createRemoteApp({
dangerouslyOmitAuth: true,
mcpConfigPath: configPath,
initialConfig: { defaultEnvironment: {} },
Expand All @@ -38,10 +39,16 @@ function setupHarness(): Harness {
: new Request(input as string | URL, init);
return app.fetch(req) as Promise<Response>;
};
return { fetchFn, configPath, tempDir };
return { fetchFn, configPath, tempDir, closeApi };
}

function teardownHarness(h: Harness): void {
async function teardownHarness(h: Harness): Promise<void> {
// Closing here releases the lazy chokidar watcher started by the SSE
// subscription useServers opens on mount. Without it the watcher would
// hang around for the lifetime of the vitest worker — harmless for the
// suite as a whole, but it would slow worker exit and could leak inotify
// watches on Linux.
await h.closeApi();
try {
rmSync(h.tempDir, { recursive: true });
} catch {
Expand All @@ -60,8 +67,8 @@ describe("useServers", () => {
h = setupHarness();
});

afterEach(() => {
teardownHarness(h);
afterEach(async () => {
await teardownHarness(h);
});

it("starts in loading state, then loads and converts the seed config", async () => {
Expand Down Expand Up @@ -218,6 +225,83 @@ describe("useServers", () => {
expect(result.current.servers.map((s) => s.id)).toEqual(["hand"]);
});

it("auto-refreshes when the /api/servers/events SSE channel signals a change", async () => {
// Mount the hook (which also opens the SSE subscription) and let the
// initial GET settle so we're observing a steady state before mutating
// the file.
const { result } = renderHook(() =>
useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }),
);
await waitFor(() => expect(result.current.loading).toBe(false));

// Give chokidar a beat to register with the now-seeded file. The lazy
// watcher inside createRemoteApp starts on the first SSE subscriber, so
// by the time the initial GET resolves the watcher is already attached.
// The pause covers any platform-specific scan-stabilization window
// before we mutate.
await new Promise((r) => setTimeout(r, 200));

// Simulate an external editor save. The watcher fires → SSE broadcasts
// → the hook's reader-loop calls refresh() without us touching the
// exposed refresh() API.
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: { external: { type: "stdio", command: "outside" } },
}) + "\n",
);

await waitFor(
() => {
expect(result.current.servers.map((s) => s.id)).toEqual(["external"]);
},
{ timeout: 3000 },
);
});

it("SSE-driven refresh does not flip loading back to true (no skeleton flicker)", async () => {
// Regression guard for the background-refresh split: a consumer
// rendering a loading spinner / skeleton must not see `loading` go
// true on every external mcp.json edit. The hook's SSE handler calls
// refreshInternal(true), which skips the setLoading toggles entirely;
// sampling a single point can miss the bug under React batching, so
// record every observed `loading` value across the SSE cycle and
// assert none of them is true.
const loadingHistory: boolean[] = [];
const { result } = renderHook(() => {
const r = useServers({
baseUrl: "http://test.local",
fetchFn: h.fetchFn,
});
loadingHistory.push(r.loading);
return r;
});

await waitFor(() => expect(result.current.loading).toBe(false));
// Slice off the mount-phase renders; only renders after the initial
// load are subject to the no-flicker contract.
const baselineLen = loadingHistory.length;

await new Promise((r) => setTimeout(r, 200));
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: { flicker: { type: "stdio", command: "outside" } },
}) + "\n",
);

await waitFor(
() => {
expect(result.current.servers.map((s) => s.id)).toEqual(["flicker"]);
},
{ timeout: 3000 },
);

const postRefreshLoadings = loadingHistory.slice(baselineLen);
expect(postRefreshLoadings.length).toBeGreaterThan(0);
expect(postRefreshLoadings.every((v) => v === false)).toBe(true);
});

it("captures the error message on fetch network failure", async () => {
const failingFetch = vi
.fn<typeof fetch>()
Expand Down
Loading
Loading