inspector/clients/web/vite.config.ts
Cliff Hall 5f9bfdc490
feat(servers): hot-reload server list on external mcp.json edits (#1345) (#1357)
* feat(servers): hot-reload server list on external mcp.json edits (#1345)

Watch ~/.mcp-inspector/mcp.json from the backend and push a change event
on a new /api/servers/events SSE channel. The web client subscribes via
fetch + ReadableStream (so the existing bearer-auth contract is
preserved — EventSource can't send custom headers) and re-fetches on
each event. The watcher is lazy: it starts on the first SSE subscriber
and stops when the last one disconnects, so tests that never open the
channel don't spin up a real fs watcher. Self-fires from the backend's
own POST/PUT/DELETE are suppressed by capturing the post-write mtime and
comparing it on each watcher event.

Chokidar is used for cross-platform editor-save semantics (temp-file +
rename produces unlink+add sequences that bare fs.watch handles poorly
on macOS/Linux); awaitWriteFinish coalesces those into a single event.

createRemoteApp now returns a close() to release the watcher, which the
standalone server and the vite dev plugin chain into their existing
close paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(servers): address PR review on hot-reload (#1357)

- writeMcpAndTrackMtime now stat()s the file before writing; if the
  mtime differs from the last value we recorded, an external editor
  slipped in between our writes and chokidar would coalesce both into
  one event whose mtime matches our own write — broadcast directly so
  peer subscribers learn about the external edit (review finding 1, 5).
- Split useServers.refresh into a public refresh() (loading toggle) and
  an internal refreshInternal(background) the SSE handler calls; the
  list now stays visible during background refreshes instead of
  flashing skeleton state on every external mcp.json edit (finding 2).
- Coalesce multiple \n\n frames landing in one decode chunk to a single
  refresh call so back-to-back broadcasts don't race two concurrent
  GETs whose setState order is unspecified (finding 3).
- Clarify createRemoteApp's close() docblock about in-flight SSE stream
  callbacks (finding 7).
- Add integration coverage for PUT/DELETE self-suppression and for the
  external-edit-between-backend-writes broadcast (finding 6).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(servers): address pass-2 review on hot-reload (#1357)

- Clarify writeMcpAndTrackMtime comment so a future reader can't
  mistake the broadcast for a content-preservation guarantee: the
  external edit's content may already have been read-around and
  overwritten depending on the interleave with readMcpConfig.
- Drop the no-op GET in the PUT/DELETE self-suppression tests. The
  pre-stat branch is short-circuited by `lastWrittenMtimeMs === null`
  with or without it; matches the POST test's structure.
- Add explicit no-flicker regression test: tracks every `loading`
  value across renders during an SSE-driven refresh and asserts none
  flipped back to true. Catches a future regression that reaches for
  setLoading(true) in the background path even under React batching.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 11:30:05 -04:00

320 lines
15 KiB
TypeScript

/// <reference types="vitest/config" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vite.dev/config/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
import { playwright } from '@vitest/browser-playwright';
import { honoMiddlewarePlugin } from './server/vite-hono-plugin';
import { getViteBaseConfig } from './server/vite-base-config';
import { buildWebServerConfigFromEnv } from './server/web-server-config';
const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(dirname, '../..');
// Aliases shared between the top-level resolve and the vitest projects.
// Vitest projects don't inherit `resolve` from the parent, so the unit and
// integration projects redeclare them — keeping a single source here prevents
// them from drifting (e.g. if a new core/* alias is added).
const sharedAliases = {
'@inspector/core': path.resolve(dirname, '../../core'),
// Point at the BUILT test-servers entry, not src/, so that any test which
// calls `getTestMcpServerPath()` (via `fileURLToPath(import.meta.url)`)
// resolves to a `.js` path Node can spawn directly as a subprocess. The
// integration tests build test-servers via `npm run test:integration`
// (or `npm run test-servers:build`) before running.
'@modelcontextprotocol/inspector-test-server': path.resolve(dirname, '../../test-servers/build/index.js'),
};
const sharedDedupe = [
'react',
'react-dom',
// The SDK is installed under both clients/web/node_modules and the repo
// root's node_modules (hoisted by npm). Without dedupe, source files in
// core/ (no local node_modules) resolve to the root copy while test files
// resolve to the clients/web copy — splitting class identity and breaking
// vi.mock() / instanceof checks (see #1307).
'@modelcontextprotocol/sdk',
];
// Bare-module aliases needed when running tests from repoRoot (which has no
// node_modules of its own). Shared between the unit and integration projects.
// Use anchored regex `find` patterns so each package's own `exports` field
// handles subpath resolution.
const nodeModulesAliases = [
{ find: /^react$/, replacement: path.resolve(dirname, 'node_modules/react') },
{ find: /^pino$/, replacement: path.resolve(dirname, 'node_modules/pino') },
{ find: /^pino\/browser\.js$/, replacement: path.resolve(dirname, 'node_modules/pino/browser.js') },
{ find: /^zustand$/, replacement: path.resolve(dirname, 'node_modules/zustand') },
{ find: /^zustand\/middleware$/, replacement: path.resolve(dirname, 'node_modules/zustand/middleware.js') },
{ find: /^zustand\/vanilla$/, replacement: path.resolve(dirname, 'node_modules/zustand/vanilla.js') },
{ find: /^hono$/, replacement: path.resolve(dirname, 'node_modules/hono/dist/index.js') },
{ find: /^hono\/streaming$/, replacement: path.resolve(dirname, 'node_modules/hono/dist/helper/streaming/index.js') },
{ find: /^@hono\/node-server$/, replacement: path.resolve(dirname, 'node_modules/@hono/node-server') },
{ find: /^atomically$/, replacement: path.resolve(dirname, 'node_modules/atomically') },
{ find: /^chokidar$/, replacement: path.resolve(dirname, 'node_modules/chokidar') },
{ find: /^express$/, replacement: path.resolve(dirname, 'node_modules/express') },
{ find: /^yaml$/, replacement: path.resolve(dirname, 'node_modules/yaml') },
// Pin the SDK auth subpath so test and source resolve to the exact same
// module ID. Without this, the source's `import` from core/auth/*.ts and
// the test's `vi.mock(...)` can resolve through different cache keys in
// Vitest's transformer pipeline — the mock then fails to intercept the
// source-side import (see #1307).
{ find: /^@modelcontextprotocol\/sdk\/client\/auth\.js$/, replacement: path.resolve(dirname, 'node_modules/@modelcontextprotocol/sdk/dist/esm/client/auth.js') },
];
// Project resolve config shared between the unit and integration projects.
// sharedAliases come first as exact-match entries, then the bare-module
// regex aliases that node-style imports from core/ rely on.
const projectResolve = {
alias: [
...Object.entries(sharedAliases).map(([find, replacement]) => ({ find, replacement })),
...nodeModulesAliases,
],
dedupe: sharedDedupe,
};
// Integration tests live under clients/web/src/test/integration/ and run in
// the node-env vitest project below. The folder is the manifest: anything
// inside it is integration (node env, 30s timeout, real servers); anything
// outside is a unit test (happy-dom). This prevents the silent
// misclassification trap where a file's environment depended on whether
// someone remembered to add it to an enumeration (#1314).
//
// Match `{ts,tsx}` to mirror the unit project's include below — otherwise a
// stray `.test.tsx` placed inside this folder would slip past the integration
// include AND fail to be excluded from unit, silently landing under happy-dom.
const integrationGlob = 'clients/web/src/test/integration/**/*.test.{ts,tsx}';
// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
export default defineConfig({
// `honoMiddlewarePlugin` is gated by `apply: 'serve'` so it only attaches
// during `vite dev` / `vite preview` — vitest projects share this config
// but never invoke `configureServer`, so the plugin stays inert there.
plugins: [react(), honoMiddlewarePlugin(buildWebServerConfigFromEnv())],
// Shared optimizeDeps exclusions so node-only packages
// (`@modelcontextprotocol/sdk/client/stdio.js`, `cross-spawn`, `which`)
// consumed by the dev backend aren't scanned for browser pre-bundling.
// Browser code reaches the node-side stack via the Hono plugin only.
...getViteBaseConfig(),
build: {
rollupOptions: {
// Loading vite.config.ts pulls `core/mcp/remote/node/server.ts` (via the
// Hono plugin) into Rollup's module cache. That chain reaches
// `core/storage/store-io.ts`, which imports node-only `atomically`. The
// module never lands in the browser bundle (it's unreachable from
// `index.html`), but Rollup's scanner still warns about the unresolved
// import. Silence it here so the build log stays clean.
onwarn(warning, defaultHandler) {
if (
warning.code === 'UNRESOLVED_IMPORT' &&
typeof warning.message === 'string' &&
warning.message.includes("'atomically'") &&
warning.message.includes('store-io.ts')
) {
return;
}
// Same story for `chokidar`: the mcp.json file watcher in
// `core/mcp/remote/node/server.ts` is reachable only through the dev
// backend, never from the browser bundle. Rollup still scans the
// import statement and warns when it can't resolve it.
if (
warning.code === 'UNRESOLVED_IMPORT' &&
typeof warning.message === 'string' &&
warning.message.includes("'chokidar'") &&
warning.message.includes('server.ts')
) {
return;
}
defaultHandler(warning);
},
},
},
resolve: {
// NOTE: the unit vitest project (below) overrides this — see comment there.
//
// Once App.tsx started consuming the full hook + state-manager surface
// (#1244), the browser dep graph reached bare-module subpaths in core/
// that Rolldown couldn't resolve against `core/`'s parent (it has no
// node_modules of its own). Promote the same bare-module aliases the
// vitest projects use so `vite dev` / `vite build` can resolve them
// from `clients/web/node_modules`.
alias: [
...Object.entries(sharedAliases).map(([find, replacement]) => ({
find,
replacement,
})),
...nodeModulesAliases,
],
// Source files in core/ import bare modules (react, @testing-library/react,
// etc.) that only exist in clients/web/node_modules. Dedupe ensures Vite
// resolves them from this package rather than walking up from core/'s
// location (which has no node_modules of its own yet).
dedupe: sharedDedupe,
},
// Pin the Vite dev server to the same port (and host) the Hono plugin
// configures from env, so `allowedOrigins` actually matches the browser
// origin. Without this, `vite dev` falls back to Vite's default 5173
// while the dev backend's `buildWebServerConfigFromEnv()` defaults to
// CLIENT_PORT=6274 — origin check rejects every `/api/*` request from
// the browser. CLIENT_PORT / HOST overrides flow through here too.
// `strictPort: true` so a port collision fails loudly instead of
// silently picking a different port (which would leave `allowedOrigins`
// pointing at the wrong host and break browser fetches).
server: {
port: parseInt(process.env.CLIENT_PORT ?? '6274', 10),
host: process.env.HOST ?? 'localhost',
strictPort: true,
fs: {
allow: [path.resolve(dirname, '../..')],
},
},
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'json-summary'],
include: [
'src/components/**/*.{ts,tsx}',
'src/utils/**/*.{ts,tsx}',
'clients/web/server/**/*.{ts,tsx}',
path.join(repoRoot, 'core/mcp/**/*.{ts,tsx}'),
path.join(repoRoot, 'core/react/**/*.{ts,tsx}'),
path.join(repoRoot, 'core/auth/**/*.{ts,tsx}'),
path.join(repoRoot, 'core/storage/**/*.{ts,tsx}'),
path.join(repoRoot, 'core/logging/**/*.{ts,tsx}'),
],
exclude: [
'**/*.stories.{ts,tsx}',
'**/*.test.{ts,tsx}',
'**/*.fixtures.{ts,tsx}',
'**/index.{ts,tsx}',
'src/components/**/types.ts',
// Dev-backend runtime glue: each file is exercised end-to-end via
// `npm run dev` (Hono plugin attaches, banner prints, /api/* serves).
// `vite-hono-plugin.ts` requires standing up a real Vite server with
// an HTTP listener to drive `configureServer`; `server.ts` is the
// production Hono entry that the v2 launcher (not yet ported, #1246)
// will invoke; `start-vite-dev-server.ts` is its dev counterpart.
// The non-glue parts are extracted to `web-server-config.ts` (fully
// tested) and `sandbox-controller.ts` (HTTP behavior tested).
'clients/web/server/vite-hono-plugin.ts',
'clients/web/server/server.ts',
'clients/web/server/start-vite-dev-server.ts',
// Pure-type modules: `interface`/`type` declarations only, no runtime
// statements. Excluding them keeps the report clean (would otherwise
// surface as misleading 0/0 rows).
path.join(repoRoot, 'core/mcp/types.ts'),
path.join(repoRoot, 'core/mcp/elicitationCreateMessage.ts'),
path.join(repoRoot, 'core/mcp/samplingCreateMessage.ts'),
path.join(repoRoot, 'core/mcp/sessionStorage.ts'),
path.join(repoRoot, 'core/mcp/inspectorClientProtocol.ts'),
path.join(repoRoot, 'core/mcp/remote/types.ts'),
// .d.ts files are declaration-only.
path.join(repoRoot, '**/*.d.ts'),
// inspectorClientEventTarget.ts is types + a single empty-body class
// (extends TypedEventTarget). v8/istanbul records 0 statements for it
// today. TODO(#1243): drop this exclusion once the class gains real
// behavior as the v1.5 InspectorClient port progresses.
path.join(repoRoot, 'core/mcp/inspectorClientEventTarget.ts'),
path.join(repoRoot, 'core/mcp/__tests__/**'),
// test-servers/ is test infrastructure (composable MCP servers and
// fixtures), not application code — its build output also lives at
// test-servers/build/, which we don't want to measure either.
path.join(repoRoot, 'test-servers/**'),
],
thresholds: {
perFile: true,
lines: 90,
statements: 85,
functions: 80,
branches: 50,
},
},
projects: [
{
extends: true,
// Vitest projects don't inherit `resolve` from the parent. The unit
// project runs from repoRoot (so vitest's coverage transformer can
// reach core/), but repoRoot has no node_modules of its own — the
// shared regex aliases redirect bare `react`/`pino`/etc. imports
// from core/ back into clients/web/node_modules.
resolve: projectResolve,
test: {
name: 'unit',
environment: 'happy-dom',
// Root the unit project at the repo root so vitest's coverage
// transformer (which only processes files inside a project root)
// can reach core/ modules. Without this, untested core/ files fall
// back to raw-TS parsing in rolldown, which can't handle TS-only
// syntax (e.g. `import type`) — silently dropping them and bypassing
// the per-file gate.
root: repoRoot,
// No `globals: true` — every test file imports `describe`, `it`,
// `expect`, `vi` explicitly from "vitest". This keeps the pattern
// consistent and avoids relying on auto-cleanup tied to Vitest's
// global lifecycle hooks; cleanup is invoked manually in setup.ts.
include: ['clients/web/src/**/*.test.{ts,tsx}'],
// Integration tests run in the integration project below (node env).
exclude: [integrationGlob],
setupFiles: [path.join(dirname, 'src/test/setup.ts')],
},
},
{
extends: true,
// See note on the unit project: integration tests also run from
// repoRoot and import core/ modules, so they need the same alias
// setup. The shared bare-module aliases keep `pino`, `hono`, etc.
// resolving against clients/web/node_modules.
resolve: projectResolve,
test: {
name: 'integration',
environment: 'node',
// Same reason as the unit project: rooted at repoRoot so vitest
// can transform core/ modules and run tests against the source.
root: repoRoot,
include: [integrationGlob],
// Integration tests spawn real HTTP/stdio servers via test-servers/,
// bind sockets, run e2e OAuth flows, and exercise filesystem-backed
// storage. 30s matches the v1.5 core/vitest.config.ts.
testTimeout: 30000,
hookTimeout: 30000,
// Inline the MCP SDK so vi.mock("@modelcontextprotocol/sdk/...")
// hooks the same transformed copy that source files import.
// Externalized node_modules are loaded via Node's loader and bypass
// Vitest's mock system. The explicit alias above pins the auth.js
// subpath to one canonical ID; inlining ensures the transformer
// pipeline owns the module rather than the Node loader.
server: {
deps: {
inline: [/@modelcontextprotocol\/sdk/],
},
},
},
},
{
extends: true,
plugins: [
// The plugin will run tests for the stories defined in your Storybook config
// See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
storybookTest({
configDir: path.join(dirname, '.storybook'),
}),
],
test: {
name: 'storybook',
browser: {
enabled: true,
headless: true,
provider: playwright({}),
instances: [
{
browser: 'chromium',
},
],
},
setupFiles: ['.storybook/vitest.setup.ts'],
},
},
],
},
});