Merge branch 'v2/main' into v2/fix/2055-ui-resource-meta-ui
This commit is contained in:
commit
5eba105c67
48 changed files with 3149 additions and 665 deletions
|
|
@ -1,14 +1,11 @@
|
|||
import type { Preview } from "@storybook/react-vite";
|
||||
import {
|
||||
MantineProvider,
|
||||
useMantineColorScheme,
|
||||
type CSSVariablesResolver,
|
||||
} from "@mantine/core";
|
||||
import { MantineProvider, useMantineColorScheme } from "@mantine/core";
|
||||
import { Notifications } from "@mantine/notifications";
|
||||
import "@mantine/core/styles.css";
|
||||
import "@mantine/notifications/styles.css";
|
||||
import "../src/App.css";
|
||||
import { theme } from "../src/theme/theme";
|
||||
import { cssVariablesResolver } from "../src/theme/cssVariables";
|
||||
import { useEffect } from "react";
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
|
|
@ -28,14 +25,6 @@ function ColorSchemeWrapper({
|
|||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const resolver: CSSVariablesResolver = () => ({
|
||||
variables: {},
|
||||
light: {},
|
||||
dark: {
|
||||
"--mantine-color-body": "var(--mantine-color-dark-9)",
|
||||
},
|
||||
});
|
||||
|
||||
const preview: Preview = {
|
||||
globalTypes: {
|
||||
colorScheme: {
|
||||
|
|
@ -61,7 +50,7 @@ const preview: Preview = {
|
|||
<MantineProvider
|
||||
theme={theme}
|
||||
defaultColorScheme="light"
|
||||
cssVariablesResolver={resolver}
|
||||
cssVariablesResolver={cssVariablesResolver}
|
||||
>
|
||||
<ColorSchemeWrapper
|
||||
colorScheme={context.globals.colorScheme ?? "light"}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ This README covers what's specific to the web client. For the repo-wide picture
|
|||
|
||||
## Two halves: `src/` (browser) and `server/` (Node)
|
||||
|
||||
| Path | Runs in | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `src/` | browser | The React SPA — components, hooks, theme, entry (`main.tsx`). |
|
||||
| `server/` | Node | The dev/prod backend wiring (never imported by the browser). |
|
||||
| Path | Runs in | Purpose |
|
||||
| --------- | ------- | ------------------------------------------------------------- |
|
||||
| `src/` | browser | The React SPA — components, hooks, theme, entry (`main.tsx`). |
|
||||
| `server/` | Node | The dev/prod backend wiring (never imported by the browser). |
|
||||
|
||||
The `server/` directory holds the Node-only backend:
|
||||
|
||||
|
|
@ -47,12 +47,12 @@ Two artifacts come out, both of which ship in the published package:
|
|||
|
||||
Components live under `src/components/` in four layers, smallest to largest:
|
||||
|
||||
| Layer | Count | What it is |
|
||||
| --- | --- | --- |
|
||||
| `elements/` | ~31 | Leaf presentational pieces (badges, buttons, toggles) over Mantine primitives. |
|
||||
| `groups/` | ~63 | Composite pieces (cards, panels, modals, control bars). |
|
||||
| `screens/` | ~11 | Full tab screens (Tools, Resources, Servers, monitoring screens…). |
|
||||
| `views/` | 1 | `InspectorView` — the top-level layout that composes the screens. |
|
||||
| Layer | Count | What it is |
|
||||
| ----------- | ----- | ------------------------------------------------------------------------------ |
|
||||
| `elements/` | ~31 | Leaf presentational pieces (badges, buttons, toggles) over Mantine primitives. |
|
||||
| `groups/` | ~63 | Composite pieces (cards, panels, modals, control bars). |
|
||||
| `screens/` | ~11 | Full tab screens (Tools, Resources, Servers, monitoring screens…). |
|
||||
| `views/` | 1 | `InspectorView` — the top-level layout that composes the screens. |
|
||||
|
||||
Every screen and element has a `*.stories.tsx` (see [Storybook](#storybook)). Styling follows the Mantine-first rules in [`AGENTS.md`](../../AGENTS.md) — theme variants and component props over CSS, `--inspector-*` tokens over raw colors.
|
||||
|
||||
|
|
@ -74,23 +74,23 @@ Nothing _enforces_ the boundary — no path alias keys off it, and the coverage
|
|||
|
||||
The Apps screen exposes a small, stable set of `data-testid` / `data-*` attributes so an automated driver (deep-link auto-open, CI review harness) can `waitForSelector` on a deterministic signal instead of sleeping. Treat these as a public contract — drivers depend on them staying stable:
|
||||
|
||||
| Attribute | Where | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `data-testid="apps-form"` | Apps content card | The container that carries the status/error attributes below. |
|
||||
| `data-app-status` | on `apps-form` | Renderer lifecycle: `idle` (nothing running) → `loading` (bridge building / `ui/initialize` in flight) → `ready` (view fired `notifications/initialized`) → `error` (bridge factory threw/rejected). Poll for `ready`. |
|
||||
| `data-app-error` | on `apps-form` | The failure reason string when `data-app-status="error"` (e.g. no connected client); absent otherwise. |
|
||||
| `data-testid="apps-error"` | error panel | Rendered below the frame when the app fails to load (factory throw/reject); shows the reason so the failure isn't a silent blank frame. |
|
||||
| `data-testid="open-app"` | Open App button | Launches the selected app. |
|
||||
| `data-testid="apps-stage"` | Stage-partial button | Snapshots the current form values for progressive-render testing. |
|
||||
| `data-testid="apps-messages"` | messages panel | `ui/message` submissions from the running view. |
|
||||
| `data-testid="apps-logs"` | app-logs panel | `notifications/message` log entries (default-expanded). |
|
||||
| Attribute | Where | Meaning |
|
||||
| ----------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `data-testid="apps-form"` | Apps content card | The container that carries the status/error attributes below. |
|
||||
| `data-app-status` | on `apps-form` | Renderer lifecycle: `idle` (nothing running) → `loading` (bridge building / `ui/initialize` in flight) → `ready` (view fired `notifications/initialized`) → `error` (bridge factory threw/rejected). Poll for `ready`. |
|
||||
| `data-app-error` | on `apps-form` | The failure reason string when `data-app-status="error"` (e.g. no connected client); absent otherwise. |
|
||||
| `data-testid="apps-error"` | error panel | Rendered below the frame when the app fails to load (factory throw/reject); shows the reason so the failure isn't a silent blank frame. |
|
||||
| `data-testid="open-app"` | Open App button | Launches the selected app. |
|
||||
| `data-testid="apps-stage"` | Stage-partial button | Snapshots the current form values for progressive-render testing. |
|
||||
| `data-testid="apps-messages"` | messages panel | `ui/message` submissions from the running view. |
|
||||
| `data-testid="apps-logs"` | app-logs panel | `notifications/message` log entries (default-expanded). |
|
||||
|
||||
App-rendered **elicitations** (#1854) render through the same `AppRenderer` but outside the Apps screen — one modal per request, from `AppElicitationHost` — and carry their own pair:
|
||||
|
||||
| Attribute | Where | Meaning |
|
||||
| --- | --- | --- |
|
||||
| Attribute | Where | Meaning |
|
||||
| ------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `data-testid="app-elicitation"` | the elicitation modal | One per in-flight app-rendered elicitation. **Absent** means the request was answered by the native elicitation form instead, which is what a driver asserts to prove the negotiation gate held. |
|
||||
| `data-app-elicitation-status` | on `app-elicitation` | The same `AppRendererStatus` for that modal's app. `ready` is when the host forwards the `elicitation/create` through its bridge. |
|
||||
| `data-app-elicitation-status` | on `app-elicitation` | The same `AppRendererStatus` for that modal's app. `ready` is when the host forwards the `elicitation/create` through its bridge. |
|
||||
|
||||
`scripts/smoke-web-elicitation.mjs` drives both halves against the public fixture (`test-servers/configs/app-elicitation-http.json` and its `-native-` sibling).
|
||||
|
||||
|
|
@ -104,20 +104,20 @@ A driver (launcher, CLI `--print-handoff`, CI review harness) can reach a **conn
|
|||
http://127.0.0.1:6274/?serverUrl=<url>&transport=http|sse&autoConnect=<token>
|
||||
```
|
||||
|
||||
| Param | Meaning |
|
||||
| --- | --- |
|
||||
| `serverUrl` | The MCP server URL. Restricted to `http:` / `https:` (a crafted `javascript:` / `data:` / `file:` value is rejected). Canonicalized via `URL.href` so it matches the OAuth store's key form. |
|
||||
| `transport` | `http` (streamable-HTTP, the default) or `sse`. Unknown values fall back to `http`. |
|
||||
| Param | Meaning |
|
||||
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `serverUrl` | The MCP server URL. Restricted to `http:` / `https:` (a crafted `javascript:` / `data:` / `file:` value is rejected). Canonicalized via `URL.href` so it matches the OAuth store's key form. |
|
||||
| `transport` | `http` (streamable-HTTP, the default) or `sse`. Unknown values fall back to `http`. |
|
||||
| `autoConnect` | **CSRF gate.** Must equal the per-launch `MCP_INSPECTOR_API_TOKEN`. The token is random per launch and only known to whatever started the server, so a third-party-minted link cannot satisfy it — this is the same exposure surface as the existing `?MCP_INSPECTOR_API_TOKEN=` param. Without a match the link is ignored. |
|
||||
|
||||
The deep link upserts a stable `deep-link` catalog row (so a reload reconnects to the same row instead of accumulating duplicates) and connects. Connection-level outcomes are surfaced on the `AppShell.Header` as a machine-readable contract, so a driver can `waitForSelector` and read the failure reason without scraping a transient toast:
|
||||
|
||||
| Attribute | Where | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `data-testid="connection-status"` | header | The element carrying the attributes below. |
|
||||
| `data-status` | on `connection-status` | The live `ConnectionStatus` (`disconnected` → `connecting` → `connected` / `error`). Poll for `connected`. |
|
||||
| `data-error-message` | on `connection-status` | Why the last connect failed (handshake error, OAuth-start failure, deep-link automation failure); absent when there is no error. |
|
||||
| `data-deeplink` | on `connection-status` | `parsed` (a valid deep link drove this load), `rejected` (deep-link params present but the token/serverUrl gate failed), or `none`. Lets a driver distinguish "no deep link" from "rejected" — both otherwise leave `data-status` idle. |
|
||||
| Attribute | Where | Meaning |
|
||||
| --------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `data-testid="connection-status"` | header | The element carrying the attributes below. |
|
||||
| `data-status` | on `connection-status` | The live `ConnectionStatus` (`disconnected` → `connecting` → `connected` / `error`). Poll for `connected`. |
|
||||
| `data-error-message` | on `connection-status` | Why the last connect failed (handshake error, OAuth-start failure, deep-link automation failure); absent when there is no error. |
|
||||
| `data-deeplink` | on `connection-status` | `parsed` (a valid deep link drove this load), `rejected` (deep-link params present but the token/serverUrl gate failed), or `none`. Lets a driver distinguish "no deep link" from "rejected" — both otherwise leave `data-status` idle. |
|
||||
|
||||
### Landing on a rendered app
|
||||
|
||||
|
|
@ -127,11 +127,11 @@ Three further params extend the deep link to pre-select — and optionally auto-
|
|||
…&openApp=<toolName>&appArgs=<base64url(JSON)>&autoOpen=<token>
|
||||
```
|
||||
|
||||
| Param | Meaning |
|
||||
| --- | --- |
|
||||
| `openApp` | The app-tool name. Once the connection is up and the tool appears in the app list, the inspector switches to the Apps tab and pre-selects it. |
|
||||
| `appArgs` | `base64url(JSON)` object of form values. Merged **over** the tool's schema defaults (`collectSchemaDefaults`) so a required-with-default field isn't left blank — which would otherwise disable "Open App". Malformed / non-object values fall back to `{}`. |
|
||||
| `autoOpen` | **Same CSRF gate as `autoConnect`** — must equal the session token. When set, "Open App" fires automatically (a tool call from a URL), so the token gate is mandatory. Without a match the app is pre-selected but not opened. |
|
||||
| Param | Meaning |
|
||||
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `openApp` | The app-tool name. Once the connection is up and the tool appears in the app list, the inspector switches to the Apps tab and pre-selects it. |
|
||||
| `appArgs` | `base64url(JSON)` object of form values. Merged **over** the tool's schema defaults (`collectSchemaDefaults`) so a required-with-default field isn't left blank — which would otherwise disable "Open App". Malformed / non-object values fall back to `{}`. |
|
||||
| `autoOpen` | **Same CSRF gate as `autoConnect`** — must equal the session token. When set, "Open App" fires automatically (a tool call from a URL), so the token gate is mandatory. Without a match the app is pre-selected but not opened. |
|
||||
|
||||
The app-side render lifecycle is observable through the [MCP Apps screen automation contract](#mcp-apps-screen-automation-contract) above (`data-app-status="ready"`), so a driver can `waitForSelector` the whole `connect → open → ready` chain deterministically.
|
||||
|
||||
|
|
@ -139,15 +139,29 @@ The app-side render lifecycle is observable through the [MCP Apps screen automat
|
|||
|
||||
Each customized Mantine component has a `Theme<Name>.ts` file (`Button.ts`, `Text.ts`, …, ~21 total) exporting a `Theme<Name>` constant; the barrel `index.ts` re-exports them and `theme.ts` assembles the `MantineProvider` theme. Theme files hold app-wide defaults and **variants** (flat CSS-in-JS); only pseudo-selectors, nested child selectors, keyframes, and native-HTML styling belong in `App.css`. Element components import from `@mantine/core` (never from `theme/`) — the theme layer is applied transparently by the provider.
|
||||
|
||||
**`cssVariables.ts` is the third piece, beside the component files and `App.css`.** It holds overrides for the CSS variables `MantineProvider` injects at runtime, which `App.css` cannot reach: the provider appends its generated `<style>` after the stylesheet imports, so a `:root` rule there loses on source order at equal specificity. `cssVariablesResolver` is the supported seam. It is passed at **all three** `MantineProvider` sites — the app (`main.tsx`), the Storybook preview, and `renderWithMantine` — so the running app, the stories, and the tests cannot disagree about a token's value. It currently corrects `--mantine-color-error`, whose Mantine defaults fail WCAG AA in both schemes at the size input error text renders.
|
||||
|
||||
## Code editing (`JsonObjectInput`)
|
||||
|
||||
Payloads whose _values_ may be arbitrary JSON — `_meta` is the case that forced it ([#1910](https://github.com/modelcontextprotocol/inspector/issues/1910)) — are edited with **Ace** (`react-ace` + `ace-builds`, declared in this client because they render React) rather than the key/value rows used for headers and env, which cannot express an object value. Ace brings code folding, brace auto-closing, and per-line error annotation from its JSON worker.
|
||||
|
||||
Three integration details are load-bearing:
|
||||
|
||||
- **The worker is imported as `?url`** so Vite emits it as an asset. Without it Ace fetches `worker-json.js` from a path that does not exist in a bundled app and silently loses its annotations.
|
||||
- **The gutter's colors are overridden in `App.css`**, keyed off Ace's cssClass (`ace-github` / `ace-github-dark` — _not_ the `theme-github_dark` module name). Ace's own themes are 1.89:1 and 4.13:1 there, both under AA, and folding needs the gutter so it cannot simply be hidden.
|
||||
- **The label and error are wired to Ace's hidden textarea by hand.** `Input.Wrapper` associates a _Mantine_ input through context; Ace renders its own DOM, so the id, `aria-invalid` and `aria-describedby` are set on the textarea in an effect.
|
||||
|
||||
**Testing it is split by necessity.** Ace's input path does not work under happy-dom — `userEvent.type` reaches the textarea and produces no edit — so a keyboard test in the unit project passes while asserting nothing. Unit tests drive the editor instance through `src/test/aceEditor.ts`; real keyboard behaviour lives in the Storybook play functions, which run in Chromium.
|
||||
|
||||
## Testing
|
||||
|
||||
Tests run under three Vitest **projects** (configured in `vite.config.ts`), each in the right environment:
|
||||
|
||||
| Project | Env | Scope | Script |
|
||||
| --- | --- | --- | --- |
|
||||
| `unit` | happy-dom | Components, hooks, utils (`*.test.tsx` beside the source) | `npm test` |
|
||||
| `integration` | node | `@inspector/core` + transports + auth, spawning the real stdio test server (`src/test/integration/**`) | `npm run test:integration` |
|
||||
| `storybook` | real Chromium | Story **play functions** as interaction tests | `npm run test:storybook` |
|
||||
| Project | Env | Scope | Script |
|
||||
| ------------- | ------------- | ------------------------------------------------------------------------------------------------------ | -------------------------- |
|
||||
| `unit` | happy-dom | Components, hooks, utils (`*.test.tsx` beside the source) | `npm test` |
|
||||
| `integration` | node | `@inspector/core` + transports + auth, spawning the real stdio test server (`src/test/integration/**`) | `npm run test:integration` |
|
||||
| `storybook` | real Chromium | Story **play functions** as interaction tests | `npm run test:storybook` |
|
||||
|
||||
- `npm test` runs the fast **unit** project (happy-dom). `test:watch` for the loop.
|
||||
- **Integration** tests run in a real Node env (no happy-dom, 30s timeouts) and spawn `test-servers/build/test-server-stdio.js` as a subprocess, so `pretest`/the coverage script build the test servers first (`test-servers:build`).
|
||||
|
|
|
|||
39
clients/web/package-lock.json
generated
39
clients/web/package-lock.json
generated
|
|
@ -16,6 +16,7 @@
|
|||
"@mantine/hooks": "^8.3.17",
|
||||
"@mantine/notifications": "^8.3.17",
|
||||
"@napi-rs/keyring": "^1.3.0",
|
||||
"ace-builds": "^1.44.0",
|
||||
"ajv": "^8.17.1",
|
||||
"atomically": "^2.1.1",
|
||||
"chokidar": "^4.0.3",
|
||||
|
|
@ -25,6 +26,7 @@
|
|||
"papaparse": "^5.5.4",
|
||||
"pino": "^9.14.0",
|
||||
"react": "^19.2.4",
|
||||
"react-ace": "^15.0.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
|
@ -4383,6 +4385,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ace-builds": {
|
||||
"version": "1.44.0",
|
||||
"resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.44.0.tgz",
|
||||
"integrity": "sha512-PFNMSYqFdEUkul2Ntud0HvA09AgY+F1ag0UYdpMH60wNI/qOA8cB8tlTgoALMEwIdUPJK2CjrIQ7OnbiSS/ugQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
||||
|
|
@ -5061,6 +5069,12 @@
|
|||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/diff-match-patch": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
|
||||
"integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
||||
|
|
@ -5487,6 +5501,15 @@
|
|||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz",
|
||||
"integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
|
|
@ -8294,6 +8317,22 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-ace": {
|
||||
"version": "15.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-15.0.0.tgz",
|
||||
"integrity": "sha512-gdmS5ftF0hsbkcrAjZQqYbXwFg5JrHuxjki8mP6Bn9kfa6lHKfZk9sU9EfS0ifQ1CpGCmRxF/VC7GRvlJMBuZw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ace-builds": "^1.36.3",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"fast-equals": "^5.3.3",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-docgen": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.3.tgz",
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
"@mantine/hooks": "^8.3.17",
|
||||
"@mantine/notifications": "^8.3.17",
|
||||
"@napi-rs/keyring": "^1.3.0",
|
||||
"ace-builds": "^1.44.0",
|
||||
"ajv": "^8.17.1",
|
||||
"atomically": "^2.1.1",
|
||||
"chokidar": "^4.0.3",
|
||||
|
|
@ -51,6 +52,7 @@
|
|||
"papaparse": "^5.5.4",
|
||||
"pino": "^9.14.0",
|
||||
"react": "^19.2.4",
|
||||
"react-ace": "^15.0.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
|
|
|||
|
|
@ -73,6 +73,16 @@
|
|||
ScrollArea would break flex sizing, e.g. the Resources accordion) blend in. */
|
||||
--inspector-scrollbar-thumb: rgba(0, 0, 0, 0.4);
|
||||
|
||||
/* ── Code editor (Ace) ─────────────────────────────────── */
|
||||
/* Ace's own `github` themes fail WCAG AA on the gutter: `#AAA` on the light
|
||||
gutter's `#e8e8e8` is 1.89:1, and `#7388b5` on the dark gutter's `#24292e`
|
||||
is 4.13:1 — both under 4.5:1 at the 12px the line numbers render. The
|
||||
numbers are `aria-hidden`, which exempts nothing here: contrast is a
|
||||
concern for sighted users, and axe flags them accordingly.
|
||||
`gray-7` is 5.82:1 on the light gutter; the dark block lifts it to
|
||||
`gray-5`, 4.88:1 on the dark one. */
|
||||
--inspector-code-gutter-text: var(--mantine-color-gray-7);
|
||||
|
||||
/* ── Connection status ─────────────────────────────────── */
|
||||
--inspector-status-connected: var(--mantine-color-green-6);
|
||||
--inspector-status-connecting: var(--mantine-color-yellow-6);
|
||||
|
|
@ -161,6 +171,9 @@
|
|||
schemes, which sits too dark on charcoal — lift them one to two steps so
|
||||
they stay legible in dark mode. Light-mode `:root` keeps the `*-6` values. */
|
||||
|
||||
/* Code editor (Ace) — see the note in `:root`. */
|
||||
--inspector-code-gutter-text: var(--mantine-color-gray-5);
|
||||
|
||||
/* Status */
|
||||
--inspector-status-connected: var(--mantine-color-green-4);
|
||||
--inspector-status-connecting: var(--mantine-color-yellow-4);
|
||||
|
|
@ -593,3 +606,12 @@ body.resizing-col {
|
|||
user-select: none;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
/* Ace renders its own DOM, so its gutter can only be reached by selector — it
|
||||
is not a Mantine component with a theme entry. The doubled class raises
|
||||
specificity above Ace's own `.ace-github .ace_gutter`, which is injected into
|
||||
<head> at runtime and therefore wins on source order otherwise. */
|
||||
.ace_editor.ace-github .ace_gutter,
|
||||
.ace_editor.ace-github-dark .ace_gutter {
|
||||
color: var(--inspector-code-gutter-text);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -220,16 +220,29 @@ const SERVER_A = {
|
|||
const { updateServerSettingsSpy } = vi.hoisted(() => ({
|
||||
updateServerSettingsSpy: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
// Same idea for the edit-modal rename path (#1914): the test needs to make the
|
||||
// PUT's *reload* fail, which only a spy it controls can do.
|
||||
const { updateServerSpy, addServerSpy } = vi.hoisted(() => ({
|
||||
updateServerSpy: vi.fn(() => Promise.resolve()),
|
||||
addServerSpy: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
// Stable spy for the tools list-changed acknowledgement, so a test can assert
|
||||
// the paginated Refresh clears the indicator (#1721).
|
||||
const { clearToolsListChangedSpy } = vi.hoisted(() => ({
|
||||
clearToolsListChangedSpy: vi.fn(),
|
||||
}));
|
||||
vi.mock("@inspector/core/react/useServers.js", () => ({
|
||||
vi.mock("@inspector/core/react/useServers.js", async (importOriginal) => ({
|
||||
// `ServerListReloadError` is a real class the pagination toggle branches on
|
||||
// with `instanceof`, so it must be the genuine one — a stub would make the
|
||||
// check vacuously false and the #1914 branch untestable (#1914 review r1).
|
||||
...(await importOriginal<
|
||||
typeof import("@inspector/core/react/useServers.js")
|
||||
>()),
|
||||
useServers: vi.fn(() => ({
|
||||
servers: [SERVER_A],
|
||||
addServer: vi.fn(),
|
||||
updateServer: vi.fn(),
|
||||
addServer: addServerSpy,
|
||||
updateServer: updateServerSpy,
|
||||
updateServerSettings: updateServerSettingsSpy,
|
||||
removeServer: vi.fn(),
|
||||
})),
|
||||
|
|
@ -435,6 +448,9 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
|||
onClearCompletedTasks: () => void;
|
||||
onRefreshTasks: () => void;
|
||||
onServerSettings: (id: string) => void;
|
||||
onServerEdit: (id: string) => void;
|
||||
onServerAdd: () => void;
|
||||
highlightedServerIds?: string[];
|
||||
onClearProtocol: () => void;
|
||||
onReplayProtocol: (id: string) => void;
|
||||
onTogglePinProtocol: (id: string) => void;
|
||||
|
|
@ -560,6 +576,14 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
|||
</button>
|
||||
<button onClick={() => props.onSetLogLevel("debug")}>set-level</button>
|
||||
<button onClick={() => props.onServerSettings("A")}>open-settings</button>
|
||||
{/* The real server grid (and its Add / Edit controls) lives inside this
|
||||
mocked view, so the config modal is only reachable through these
|
||||
callbacks — and the highlight batch only observable through this prop. */}
|
||||
<button onClick={() => props.onServerEdit("A")}>edit-server</button>
|
||||
<button onClick={() => props.onServerAdd()}>add-server</button>
|
||||
<span data-testid="highlighted-servers">
|
||||
{(props.highlightedServerIds ?? []).join(",") || "none"}
|
||||
</span>
|
||||
<span data-testid="pinned-history">
|
||||
{Array.from(props.pinnedProtocolIds ?? []).join(",")}
|
||||
</span>
|
||||
|
|
@ -607,7 +631,10 @@ import { usePagedTools } from "@inspector/core/react/usePagedTools.js";
|
|||
import { useMessageLog } from "@inspector/core/react/useMessageLog.js";
|
||||
import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js";
|
||||
import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft.js";
|
||||
import { useServers } from "@inspector/core/react/useServers.js";
|
||||
import {
|
||||
ServerListReloadError,
|
||||
useServers,
|
||||
} from "@inspector/core/react/useServers.js";
|
||||
import type {
|
||||
InspectorServerSettings,
|
||||
MessageEntry,
|
||||
|
|
@ -1665,7 +1692,7 @@ const settingsWithRoots = (
|
|||
): InspectorServerSettings => ({
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
metadata: {},
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 60000,
|
||||
|
|
@ -2990,6 +3017,236 @@ describe("App paginated list pagination toggle (#1721)", () => {
|
|||
};
|
||||
expect(lastPush?.paginatedLists).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a modal save whose list reload failed as landed (#1914 + #2089)", async () => {
|
||||
// A `ServerListReloadError` rejects *after* the PUT landed, so the save is
|
||||
// on disk. It has to be recorded as the last landed write and applied like
|
||||
// any other settled one — the failure path would roll the UI, the live
|
||||
// client and the modal's seed back to a value disk no longer holds.
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<App />);
|
||||
await user.click(screen.getByText("connect"));
|
||||
await waitFor(() => expect(clientInstances).toHaveLength(1));
|
||||
|
||||
const draftOptions = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0];
|
||||
if (!draftOptions) throw new Error("useSettingsDraft was never called");
|
||||
updateServerSettingsSpy.mockRejectedValueOnce(
|
||||
new ServerListReloadError(
|
||||
"The server was saved, but the server list could not be reloaded: disk full",
|
||||
),
|
||||
);
|
||||
const saved: InspectorServerSettings = {
|
||||
...settingsWithRoots([]),
|
||||
paginatedLists: true,
|
||||
};
|
||||
await act(async () => {
|
||||
await draftOptions.onPersist("A", saved).catch(() => undefined);
|
||||
});
|
||||
|
||||
// `servers` is mocked to a fixed entry here — exactly what a failed reload
|
||||
// leaves — so both of these read the record rather than the entry.
|
||||
expect(draftOptions.resolveInitial("A")).toEqual(
|
||||
expect.objectContaining({ paginatedLists: true }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the optimistic toggle when only the list reload failed (#1914)", async () => {
|
||||
// The mirror of the test above. `refreshAfterWrite` rejects with a
|
||||
// `ServerListReloadError` when the PUT landed and only reading the list
|
||||
// back failed — the setting IS on disk, so the #1721 rollback would put
|
||||
// the UI and the live client on the value the user just changed away
|
||||
// from, and contradict what a reload would show.
|
||||
const user = userEvent.setup();
|
||||
updateServerSettingsSpy.mockRejectedValueOnce(
|
||||
new ServerListReloadError(
|
||||
"The server was saved, but the server list could not be reloaded: disk full",
|
||||
),
|
||||
);
|
||||
renderWithMantine(<App />);
|
||||
await user.click(screen.getByText("connect"));
|
||||
await waitFor(() => expect(clientInstances).toHaveLength(1));
|
||||
|
||||
await user.click(screen.getByText("paginated-on"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"),
|
||||
);
|
||||
// Give the rejection a turn to land, then confirm nothing reverted it.
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true");
|
||||
// Single intersection assertion rather than the `as unknown as` this file
|
||||
// uses elsewhere: the fake client really is an EventTarget, and widening
|
||||
// it keeps that relationship instead of erasing it (#1914 r2).
|
||||
const client = clientInstances[0] as EventTarget & {
|
||||
setServerSettings: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
const lastPush = client.setServerSettings.mock.calls.at(-1)?.[0] as {
|
||||
paginatedLists?: boolean;
|
||||
};
|
||||
expect(lastPush?.paginatedLists).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// A post-write reload failure rejects *after* the write landed, so every piece
|
||||
// of state `onConfigSubmit` used to apply on the resolve path has to be applied
|
||||
// on the reject path too — the rename target and the highlight batch both
|
||||
// describe a row that really is on disk (#1914 review r2 / r3).
|
||||
describe("App config submit with a failed list reload (#1914)", () => {
|
||||
// Earlier describes install their own `useServers` return value, which
|
||||
// outlives them — so this block installs its own and puts the previous
|
||||
// implementation back, rather than trusting whatever leaked in.
|
||||
let restoreUseServers: (() => void) | undefined;
|
||||
|
||||
const serversResult = (ids: string[]): ReturnType<typeof useServers> => ({
|
||||
servers: ids.map((id) => ({ ...SERVER_A, id }) as ServerEntry),
|
||||
loading: false,
|
||||
error: undefined,
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
addServer: addServerSpy,
|
||||
updateServer: updateServerSpy,
|
||||
updateServerSettings: updateServerSettingsSpy,
|
||||
removeServer: vi.fn().mockResolvedValue(undefined),
|
||||
reorderServers: vi.fn().mockResolvedValue(undefined),
|
||||
importSource: vi.fn().mockResolvedValue({ servers: {} }),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
clientInstances.length = 0;
|
||||
const previous = vi.mocked(useServers).getMockImplementation();
|
||||
restoreUseServers = previous
|
||||
? () => vi.mocked(useServers).mockImplementation(previous)
|
||||
: undefined;
|
||||
updateServerSpy.mockClear();
|
||||
addServerSpy.mockClear();
|
||||
updateServerSettingsSpy.mockClear();
|
||||
vi.mocked(useServers).mockReturnValue(serversResult(["A"]));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreUseServers?.();
|
||||
});
|
||||
|
||||
it("follows the rename so the active id isn't orphaned (#1914)", async () => {
|
||||
// `activeServerId` isn't rendered anywhere, so it's read back through the
|
||||
// one handler that passes it straight to a spy: the pagination toggle
|
||||
// calls `updateServerSettings(activeServerId, …)`.
|
||||
const user = userEvent.setup();
|
||||
updateServerSpy.mockRejectedValueOnce(
|
||||
new ServerListReloadError(
|
||||
"The server was saved, but the server list could not be reloaded: disk full",
|
||||
),
|
||||
);
|
||||
const { rerender } = renderWithMantine(<App />);
|
||||
await user.click(screen.getByText("connect"));
|
||||
await waitFor(() => expect(clientInstances).toHaveLength(1));
|
||||
|
||||
await user.click(screen.getByText("edit-server"));
|
||||
const idField = await screen.findByLabelText(/Server ID/);
|
||||
await user.clear(idField);
|
||||
await user.type(idField, "A-renamed");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateServerSpy).toHaveBeenCalledWith(
|
||||
"A",
|
||||
"A-renamed",
|
||||
expect.anything(),
|
||||
),
|
||||
);
|
||||
// The rejection still reaches the modal, which stays open showing it.
|
||||
expect(
|
||||
await screen.findByText(/could not be reloaded: disk full/),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Now let the row land, as the SSE refresh the write itself triggers
|
||||
// would. The modal's target ("A") is gone, so it closes rather than
|
||||
// blanking its own form — `initialId`/`initialConfig` would go undefined
|
||||
// and ServerConfigModal's reset would wipe the open form and the error
|
||||
// it is showing (#1914 r3).
|
||||
vi.mocked(useServers).mockReturnValue(serversResult(["A-renamed"]));
|
||||
rerender(<App />);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText(/Server ID/)).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// With the modal gone, read the active id back off the toggle.
|
||||
await user.click(screen.getByText("paginated-on"));
|
||||
await waitFor(() =>
|
||||
expect(updateServerSettingsSpy).toHaveBeenCalledWith(
|
||||
"A-renamed",
|
||||
expect.objectContaining({ paginatedLists: true }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("labels a settings save whose reload failed as saved, not failed (#1914)", () => {
|
||||
// The settings draft debounces and flushes on close, so this toast is the
|
||||
// user's only signal — a flush that rejects usually does so after the
|
||||
// modal is gone. `useSettingsDraft` is mocked here, so the App's `onError`
|
||||
// is invoked directly with the options it was handed.
|
||||
renderWithMantine(<App />);
|
||||
const onError = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0].onError;
|
||||
expect(onError).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
onError!(
|
||||
"A",
|
||||
new ServerListReloadError(
|
||||
"The server was saved, but the server list could not be reloaded: disk full",
|
||||
),
|
||||
);
|
||||
});
|
||||
expect(notificationsMock.show).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Saved settings for "A", but the server list did not reload',
|
||||
message: expect.stringContaining("disk full"),
|
||||
}),
|
||||
);
|
||||
|
||||
// A genuinely failed write still reads as a failure.
|
||||
act(() => {
|
||||
onError!("A", new Error("disk full"));
|
||||
});
|
||||
expect(notificationsMock.show).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Failed to save settings for "A"' }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a successful add in the highlight batch when the reload failed (#1914)", async () => {
|
||||
// `addServerHighlighted` marked the new id only after `addServer`
|
||||
// resolved. The row is on disk either way, so on a reload failure it has
|
||||
// to be marked from the catch — the next successful refresh is what
|
||||
// renders it, and it would otherwise arrive unhighlighted.
|
||||
const user = userEvent.setup();
|
||||
addServerSpy.mockRejectedValueOnce(
|
||||
new ServerListReloadError(
|
||||
"The server was added, but the server list could not be reloaded: disk full",
|
||||
),
|
||||
);
|
||||
renderWithMantine(<App />);
|
||||
expect(screen.getByTestId("highlighted-servers")).toHaveTextContent("none");
|
||||
|
||||
await user.click(screen.getByText("add-server"));
|
||||
await user.type(await screen.findByLabelText(/Server ID/), "brand-new");
|
||||
await user.type(screen.getByLabelText(/^Command/), "node");
|
||||
await user.click(screen.getByRole("button", { name: "Add" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(addServerSpy).toHaveBeenCalledWith("brand-new", expect.anything()),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(/could not be reloaded: disk full/),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("highlighted-servers")).toHaveTextContent(
|
||||
"brand-new",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// A background command runs through `runCommandInBackground`, which owns the
|
||||
|
|
|
|||
|
|
@ -99,7 +99,10 @@ import {
|
|||
} from "@inspector/core/auth/index.js";
|
||||
import { RemoteInspectorClientStorage } from "@inspector/core/mcp/remote/index.js";
|
||||
import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js";
|
||||
import { useServers } from "@inspector/core/react/useServers.js";
|
||||
import {
|
||||
ServerListReloadError,
|
||||
useServers,
|
||||
} from "@inspector/core/react/useServers.js";
|
||||
import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft.js";
|
||||
import { useClientSettingsDraft } from "@inspector/core/react/useClientSettingsDraft.js";
|
||||
import { useEmaIdpLoginState } from "@inspector/core/react/useEmaIdpLoginState.js";
|
||||
|
|
@ -387,7 +390,7 @@ async function replayProtocolRequest(
|
|||
const EMPTY_SETTINGS: InspectorServerSettings = {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
metadata: {},
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: DEFAULT_TASK_TTL_MS,
|
||||
|
|
@ -2567,13 +2570,9 @@ function App() {
|
|||
const activeCimdUrl = getActiveCimdClientMetadataUrl(clientConfig);
|
||||
// Flatten the persisted settings into the InspectorClient options shape.
|
||||
// Empty / zero values stay unset so the SDK defaults apply.
|
||||
const defaultMetadata = savedSettings?.metadata
|
||||
? Object.fromEntries(
|
||||
savedSettings.metadata
|
||||
.filter((m) => m.key.trim() !== "")
|
||||
.map((m) => [m.key, m.value]),
|
||||
)
|
||||
: undefined;
|
||||
// Per-server default `_meta` is already a JSON object (#1910) — no
|
||||
// pair-array flattening left to do; `{}` means "no defaults".
|
||||
const defaultMetadata = savedSettings?.metadata;
|
||||
const serverAuthorizationParams = savedSettings
|
||||
? oauthAuthorizationParamsFromSettings(savedSettings)
|
||||
: undefined;
|
||||
|
|
@ -3866,30 +3865,46 @@ function App() {
|
|||
// are ordered by when they were issued rather than by which one's list
|
||||
// reload finished first (#2089).
|
||||
const write = lastPersistedSettings.begin(activeServerId);
|
||||
// This value is on disk now. Remember it as the rollback baseline for
|
||||
// whatever is written next, since the `servers` entry it was derived
|
||||
// from will keep describing the old value if the reload behind this
|
||||
// write — or any later one — fails (#2089).
|
||||
//
|
||||
// Re-apply it when this write is the settled one: an overlapping toggle
|
||||
// that failed *first* rolled the UI and the live client back to a
|
||||
// baseline this write has since replaced, and if the list read behind
|
||||
// this write failed too, nothing else would ever correct them. Through
|
||||
// the *current* client, not this continuation's closure: a reconnect to
|
||||
// the same server passes the id check while the captured instance is
|
||||
// already destroyed.
|
||||
const settlePaginationWrite = () => {
|
||||
const settled = write.landed(next);
|
||||
if (settled && activeServerIdRef.current === activeServerId) {
|
||||
setPaginatedListsOverride(next.paginatedLists ?? false);
|
||||
applyLiveServerSettings(next);
|
||||
}
|
||||
};
|
||||
void refreshingPersist(updateServerSettings, refreshInitialConfig)(
|
||||
activeServerId,
|
||||
next,
|
||||
)
|
||||
.then(() => {
|
||||
// This value is on disk now. Remember it as the rollback baseline for
|
||||
// whatever is written next, since the `servers` entry it was derived
|
||||
// from will keep describing the old value if the reload behind this
|
||||
// write — or any later one — fails (#2089).
|
||||
//
|
||||
// Re-apply it when this write is the settled one: an overlapping
|
||||
// toggle that failed *first* rolled the UI and the live client back
|
||||
// to a baseline this write has since replaced, and if the list read
|
||||
// behind this write failed too, nothing else would ever correct them.
|
||||
// Through the *current* client, not this continuation's closure: a
|
||||
// reconnect to the same server passes the id check while the captured
|
||||
// instance is already destroyed.
|
||||
const settled = write.landed(next);
|
||||
if (settled && activeServerIdRef.current === activeServerId) {
|
||||
setPaginatedListsOverride(next.paginatedLists ?? false);
|
||||
applyLiveServerSettings(next);
|
||||
}
|
||||
})
|
||||
.then(settlePaginationWrite)
|
||||
.catch((err: unknown) => {
|
||||
// A `ServerListReloadError` means the PUT landed and only reading the
|
||||
// list back failed, so the new setting IS on disk (#1914). That is a
|
||||
// landed write, not a failed one: rolling back would put the UI and
|
||||
// the live client on the *old* value and contradict disk. Settle it
|
||||
// exactly as the success path does and report only the failed reload.
|
||||
if (err instanceof ServerListReloadError) {
|
||||
settlePaginationWrite();
|
||||
notifications.show({
|
||||
title:
|
||||
"Pagination setting saved, but the server list did not reload",
|
||||
message: err.message,
|
||||
color: "red",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// This write is over and never reached disk, so it stops counting as
|
||||
// in flight: an earlier write still running is the settled state once
|
||||
// it lands, and is what re-applies the UI this rollback is about to
|
||||
|
|
@ -4217,8 +4232,20 @@ function App() {
|
|||
// opens (see the menu handlers).
|
||||
const addServerHighlighted = useCallback(
|
||||
async (id: string, config: MCPServerConfig) => {
|
||||
await addServer(id, config);
|
||||
setHighlightedServerIds((ids) => (ids.includes(id) ? ids : [...ids, id]));
|
||||
const markAdded = () =>
|
||||
setHighlightedServerIds((ids) =>
|
||||
ids.includes(id) ? ids : [...ids, id],
|
||||
);
|
||||
try {
|
||||
await addServer(id, config);
|
||||
} catch (err) {
|
||||
// A failed list reload still means the row is on disk, so it belongs
|
||||
// in the highlight batch — the next successful refresh is what
|
||||
// renders it, and it would otherwise arrive unmarked (#1914 r2).
|
||||
if (err instanceof ServerListReloadError) markAdded();
|
||||
throw err;
|
||||
}
|
||||
markAdded();
|
||||
},
|
||||
[addServer],
|
||||
);
|
||||
|
|
@ -4228,10 +4255,24 @@ function App() {
|
|||
async (id: string, config: MCPServerConfig) => {
|
||||
if (configModal?.mode === "edit" && configModal.targetId) {
|
||||
const originalId = configModal.targetId;
|
||||
await updateServer(originalId, id, config);
|
||||
if (originalId === activeServerId && id !== originalId) {
|
||||
setActiveServerId(id);
|
||||
const followRename = () => {
|
||||
if (originalId === activeServerId && id !== originalId) {
|
||||
setActiveServerId(id);
|
||||
}
|
||||
};
|
||||
try {
|
||||
await updateServer(originalId, id, config);
|
||||
} catch (err) {
|
||||
// A `ServerListReloadError` means the PUT landed and only reading
|
||||
// the list back failed — the rename IS on disk. The active
|
||||
// selection has to follow it anyway, or the next successful
|
||||
// refresh leaves `activeServerId` pointing at an id that no longer
|
||||
// exists (#1914 r2). Still rethrow, so the modal shows the reload
|
||||
// error rather than closing as if nothing happened.
|
||||
if (err instanceof ServerListReloadError) followRename();
|
||||
throw err;
|
||||
}
|
||||
followRename();
|
||||
return;
|
||||
}
|
||||
// add or clone
|
||||
|
|
@ -4256,6 +4297,30 @@ function App() {
|
|||
return servers.find((s) => s.id === configModal.targetId);
|
||||
}, [configModal, servers]);
|
||||
|
||||
// An edit/clone modal whose target has left the list can no longer render a
|
||||
// coherent form: `initialId`/`initialConfig` go undefined, and
|
||||
// ServerConfigModal's own `useValueChange` reset then blanks the open form
|
||||
// *and* clears the error it is showing.
|
||||
//
|
||||
// Only reachable since #1914 made the modal stay open on a failed post-write
|
||||
// reload: rename A → B, the PUT lands, the reload fails, and the modal sits
|
||||
// there targeting A. The write itself is what triggers the backend's change
|
||||
// broadcast, so the SSE-driven background refresh that lands B (and drops A)
|
||||
// is the *likely* next event, not a remote one. Closing is right — the edit
|
||||
// is on disk and the list now agrees — and it covers the ordinary case too,
|
||||
// where an external mcp.json edit removes the server being edited.
|
||||
//
|
||||
// Adjusted during render rather than in an effect (see `useValueChange`), so
|
||||
// no frame ever paints the blanked form.
|
||||
useValueChange(
|
||||
configModal?.mode === "add" || configModal?.targetId === undefined
|
||||
? true
|
||||
: servers.some((s) => s.id === configModal.targetId),
|
||||
(targetPresent) => {
|
||||
if (!targetPresent) setConfigModal(null);
|
||||
},
|
||||
);
|
||||
|
||||
const settingsModalTarget = useMemo(() => {
|
||||
if (!settingsModalTargetId) return undefined;
|
||||
return servers.find((s) => s.id === settingsModalTargetId);
|
||||
|
|
@ -4306,12 +4371,27 @@ function App() {
|
|||
// flush can put two saves for one server in flight, and the later-issued
|
||||
// one describes disk however the two happen to finish (#2089).
|
||||
const write = lastPersistedSettings.begin(id);
|
||||
const settleSettingsWrite = () => {
|
||||
const settled = write.landed(value);
|
||||
if (settled && activeServerIdRef.current === id) {
|
||||
setPaginatedListsOverride(value.paginatedLists ?? false);
|
||||
applyLiveServerSettings(value);
|
||||
}
|
||||
};
|
||||
try {
|
||||
await refreshingPersist(updateServerSettings, refreshInitialConfig)(
|
||||
id,
|
||||
value,
|
||||
);
|
||||
} catch (err) {
|
||||
// A `ServerListReloadError` means the PUT landed and only reading the
|
||||
// list back failed, so this write did reach disk (#1914): record it
|
||||
// like the success path below instead of rolling anything back, and
|
||||
// rethrow so `onError` can say the reload — not the save — failed.
|
||||
if (err instanceof ServerListReloadError) {
|
||||
settleSettingsWrite();
|
||||
throw err;
|
||||
}
|
||||
// Stop counting as in flight before the rejection goes on to
|
||||
// `onError` — an earlier write still running settles the state once it
|
||||
// lands, and cannot know that while this one looks pending (#2089).
|
||||
|
|
@ -4338,11 +4418,7 @@ function App() {
|
|||
// toggle failing while this save is in flight rolls the UI and the live
|
||||
// client back to a baseline this save then replaces on disk, so a settled
|
||||
// save has to re-apply itself exactly as a settled toggle does (#2089).
|
||||
const settled = write.landed(value);
|
||||
if (settled && activeServerIdRef.current === id) {
|
||||
setPaginatedListsOverride(value.paginatedLists ?? false);
|
||||
applyLiveServerSettings(value);
|
||||
}
|
||||
settleSettingsWrite();
|
||||
},
|
||||
// Surface failures via toast — the modal usually closes
|
||||
// immediately on user dismiss, so a silent fail-on-flush would
|
||||
|
|
@ -4350,7 +4426,14 @@ function App() {
|
|||
// didn't (especially painful for the OAuth client secret).
|
||||
onError: (id, err) => {
|
||||
notifications.show({
|
||||
title: `Failed to save settings for "${id}"`,
|
||||
// A `ServerListReloadError` means the PUT landed and only reading the
|
||||
// list back failed, so "Failed to save" would be false — and this
|
||||
// toast is the user's only signal here, since the modal has usually
|
||||
// closed by the time a flush rejects (#1914 r3).
|
||||
title:
|
||||
err instanceof ServerListReloadError
|
||||
? `Saved settings for "${id}", but the server list did not reload`
|
||||
: `Failed to save settings for "${id}"`,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
color: "red",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createRef, StrictMode } from "react";
|
||||
import { act } from "@testing-library/react";
|
||||
import { act, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import type { CallToolResult, Tool } from "@modelcontextprotocol/client";
|
||||
|
|
@ -546,15 +546,21 @@ describe("AppRenderer", () => {
|
|||
// MutationObserver callbacks are delivered on a microtask.
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(bridge.sendHostContextChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
theme: "dark",
|
||||
styles: expect.objectContaining({
|
||||
variables: expect.objectContaining({
|
||||
"--color-background-primary": "#101113",
|
||||
// `waitFor`, not a bare assertion after the single microtask above: how
|
||||
// many ticks happy-dom takes to deliver the MutationObserver callback is
|
||||
// load-dependent, so under a full parallel run the observer can still be
|
||||
// pending here. Observed failing exactly once that way, with 0 calls.
|
||||
await waitFor(() =>
|
||||
expect(bridge.sendHostContextChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
theme: "dark",
|
||||
styles: expect.objectContaining({
|
||||
variables: expect.objectContaining({
|
||||
"--color-background-primary": "#101113",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
getComputedStyleSpy.mockRestore();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,217 @@
|
|||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, waitFor, within } from "storybook/test";
|
||||
import type { StrictJsonObject } from "@inspector/core/json/jsonUtils.js";
|
||||
import { JsonObjectInput } from "./JsonObjectInput";
|
||||
|
||||
const LABEL = "Request metadata JSON";
|
||||
|
||||
/**
|
||||
* The component is controlled, so every story drives it through a small
|
||||
* stateful wrapper — otherwise the editor would not accept a keystroke and the
|
||||
* play functions could not exercise the draft behavior at all.
|
||||
*
|
||||
* These stories run in real Chromium, which is what makes them the *only*
|
||||
* place Ace's keyboard behavior can be tested: the unit suite runs in
|
||||
* happy-dom, where Ace's hidden-textarea input path does not work at all.
|
||||
*/
|
||||
function ControlledJsonObjectInput({ initial }: { initial: StrictJsonObject }) {
|
||||
const [value, setValue] = useState<StrictJsonObject>(initial);
|
||||
return (
|
||||
<JsonObjectInput
|
||||
label="Request metadata"
|
||||
ariaLabel={LABEL}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof ControlledJsonObjectInput> = {
|
||||
title: "Elements/JsonObjectInput",
|
||||
component: ControlledJsonObjectInput,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ControlledJsonObjectInput>;
|
||||
|
||||
/** The live editor instance, which Ace hangs off its container as `env.editor`. */
|
||||
function aceEditor(canvasElement: HTMLElement): {
|
||||
focus(): void;
|
||||
selectAll(): void;
|
||||
getValue(): string;
|
||||
session: { getAnnotations(): { row: number; type: string }[] };
|
||||
} {
|
||||
const container = canvasElement.querySelector(".ace_editor");
|
||||
const editor = (
|
||||
container as
|
||||
| (HTMLElement & {
|
||||
env?: {
|
||||
editor?: {
|
||||
focus(): void;
|
||||
selectAll(): void;
|
||||
getValue(): string;
|
||||
session: { getAnnotations(): { row: number; type: string }[] };
|
||||
};
|
||||
};
|
||||
})
|
||||
| null
|
||||
)?.env?.editor;
|
||||
if (!editor) throw new Error("Ace editor not mounted");
|
||||
return editor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the editor and empty it, so the next keystrokes are the whole document
|
||||
* and the typing itself — the thing under test — is left to `userEvent`.
|
||||
*
|
||||
* Emptied rather than merely selected: typing an opening brace or quote with a
|
||||
* selection makes Ace **wrap** the selection instead of replacing it, so typing
|
||||
* `{` over a selected `{}` yields `{{}}` and the assertion stops being about
|
||||
* auto-closing at all.
|
||||
*
|
||||
* Focus and select go through Ace's API on purpose. `userEvent.click` does not
|
||||
* focus the editor — Ace's input is a 1px offscreen textarea, so the click
|
||||
* lands nowhere and the keystrokes reach the body instead. And select-all is
|
||||
* not `Control+A` everywhere (Ace binds `Cmd-A` on mac), so pressing it would
|
||||
* make these stories pass or fail by host OS.
|
||||
*/
|
||||
async function focusAndClear(canvasElement: HTMLElement): Promise<void> {
|
||||
const editor = aceEditor(canvasElement);
|
||||
editor.focus();
|
||||
editor.selectAll();
|
||||
await userEvent.keyboard("{Backspace}");
|
||||
}
|
||||
|
||||
/** Ace renders each token in its own element, so read the document, not text. */
|
||||
function editorText(canvasElement: HTMLElement): string {
|
||||
return aceEditor(canvasElement).getValue();
|
||||
}
|
||||
|
||||
/** A structured payload renders as formatted JSON, nesting intact. */
|
||||
export const Populated: Story = {
|
||||
args: {
|
||||
initial: {
|
||||
tenant: "acme",
|
||||
trace: { id: "abc123", sampled: true },
|
||||
features: ["apps", "tasks"],
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
await expect(JSON.parse(editorText(canvasElement))).toEqual({
|
||||
tenant: "acme",
|
||||
trace: { id: "abc123", sampled: true },
|
||||
features: ["apps", "tasks"],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/** An empty payload opens as `{}` and carries no error. */
|
||||
export const Empty: Story = {
|
||||
args: { initial: {} },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(editorText(canvasElement)).toBe("{}");
|
||||
await expect(canvas.queryByText(/Not valid JSON/)).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Opening a brace closes it — one of the two reasons this is an editor rather
|
||||
* than a textarea, and the thing that makes hand-writing a nested `_meta`
|
||||
* payload bearable.
|
||||
*
|
||||
* Deliberately one keystroke: Ace also auto-pairs quotes and skips over a
|
||||
* closing character you type yourself, so a longer sequence asserts the sum of
|
||||
* several behaviours and stops being a readable statement about this one.
|
||||
*/
|
||||
export const ClosesBracesWhileTyping: Story = {
|
||||
args: { initial: {} },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await focusAndClear(canvasElement);
|
||||
// `{{` is userEvent's escape for a literal `{`.
|
||||
await userEvent.keyboard("{{");
|
||||
// Ace does not close it on the keystroke — an opening brace at the end of a
|
||||
// line is often the start of a block the user is about to fill in.
|
||||
await expect(editorText(canvasElement)).toBe("{");
|
||||
// Enter is what completes the pair, leaving the cursor indented between
|
||||
// them. This is the behaviour that makes writing nested `_meta` by hand
|
||||
// bearable, and the reason this is an editor rather than a textarea.
|
||||
await userEvent.keyboard("{Enter}");
|
||||
await expect(editorText(canvasElement)).toBe("{\n \n}");
|
||||
// The result parses, so nothing is flagged.
|
||||
await expect(canvas.queryByText(/Not valid JSON/)).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Half-typed text is displayed as typed and flagged, rather than being
|
||||
* re-escaped back into the box each keystroke (the #1928 failure this draft
|
||||
* split exists to avoid).
|
||||
*/
|
||||
export const InvalidDraft: Story = {
|
||||
args: { initial: {} },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await focusAndClear(canvasElement);
|
||||
// Genuinely half-typed — the state a user is in partway through writing an
|
||||
// entry, rather than a contrived bad string.
|
||||
await userEvent.keyboard('{{"a":');
|
||||
// Shown exactly as typed. The old textarea re-escaped its own contents on
|
||||
// every keystroke here, which is what made it unusable (#1928).
|
||||
await expect(editorText(canvasElement)).toBe('{"a":');
|
||||
await expect(await canvas.findByText(/Not valid JSON/)).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Valid JSON that is not an object is rejected with its own message, distinct
|
||||
* from unparseable text.
|
||||
*
|
||||
* A bare number rather than an array: brackets are auto-paired too, so typing
|
||||
* one would test the pairing as much as the rejection.
|
||||
*/
|
||||
export const NotAnObject: Story = {
|
||||
args: { initial: {} },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await focusAndClear(canvasElement);
|
||||
await userEvent.keyboard("42");
|
||||
await expect(editorText(canvasElement)).toBe("42");
|
||||
await expect(
|
||||
await canvas.findByText(/Must be a JSON object/),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The JSON worker marks the offending line in the gutter — the reason this
|
||||
* component registers `worker-json` through `ace.config.setModuleUrl` with a
|
||||
* Vite `?url` asset.
|
||||
*
|
||||
* Asserted because nothing else covers it: the worker runs out-of-process, so
|
||||
* if its URL registration breaks, Ace fails to start it and silently falls back
|
||||
* to no annotations. Every other test here — including the wrapper's own
|
||||
* "Not valid JSON" message, which comes from our parser, not from Ace — would
|
||||
* still pass while the gutter diagnostics quietly disappeared.
|
||||
*/
|
||||
export const AnnotatesTheOffendingLine: Story = {
|
||||
args: { initial: {} },
|
||||
play: async ({ canvasElement }) => {
|
||||
await focusAndClear(canvasElement);
|
||||
// A trailing comma on line 2: valid-looking until the closing brace, so the
|
||||
// worker has to locate it rather than just reject the document.
|
||||
await userEvent.keyboard('{{"a": 1,{Enter}}');
|
||||
await waitFor(
|
||||
async () => {
|
||||
const annotations = aceEditor(canvasElement).session.getAnnotations();
|
||||
await expect(annotations.length).toBeGreaterThan(0);
|
||||
await expect(annotations.some((a) => a.type === "error")).toBe(true);
|
||||
},
|
||||
// The worker is asynchronous and debounced, so this needs a real wait
|
||||
// rather than a tick.
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
import { useState } from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { act, screen } from "@testing-library/react";
|
||||
import type { StrictJsonObject } from "@inspector/core/json/jsonUtils.js";
|
||||
import { renderWithMantine } from "../../../test/renderWithMantine";
|
||||
import { aceLabel, getAceText, setAceText } from "../../../test/aceEditor";
|
||||
import { JsonObjectInput } from "./JsonObjectInput";
|
||||
|
||||
const LABEL = "Payload JSON";
|
||||
|
||||
/** Controlled harness: the component never owns the value, only the draft. */
|
||||
function Harness({
|
||||
initial,
|
||||
onChange,
|
||||
}: {
|
||||
initial: StrictJsonObject;
|
||||
onChange?: (v: StrictJsonObject) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState<StrictJsonObject>(initial);
|
||||
return (
|
||||
<>
|
||||
<JsonObjectInput
|
||||
ariaLabel={LABEL}
|
||||
value={value}
|
||||
onChange={(next) => {
|
||||
setValue(next);
|
||||
onChange?.(next);
|
||||
}}
|
||||
/>
|
||||
<output data-testid="emitted">{JSON.stringify(value)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("JsonObjectInput", () => {
|
||||
it("names the editor for assistive tech", () => {
|
||||
renderWithMantine(<Harness initial={{}} />);
|
||||
// Ace composes "<label>, Cursor at row N", so the match is by substring —
|
||||
// the position readout is Ace's, the name is ours.
|
||||
expect(screen.getByLabelText(aceLabel(LABEL))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses Ace's dark theme under a dark color scheme", () => {
|
||||
// The editor picks its Ace theme from the computed scheme rather than
|
||||
// inheriting Mantine tokens, so the dark branch is only exercised by
|
||||
// actually rendering dark.
|
||||
renderWithMantine(<Harness initial={{}} />, { colorScheme: "dark" });
|
||||
// `ace-github-dark`, hyphenated — Ace's cssClass, not the module name
|
||||
// (`theme-github_dark`). The App.css gutter-contrast override keys off this
|
||||
// same class, so getting it wrong silently drops the fix in dark mode.
|
||||
expect(document.querySelector(".ace-github-dark")).not.toBeNull();
|
||||
expect(document.querySelector(".ace-github")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses Ace's light theme under a light color scheme", () => {
|
||||
renderWithMantine(<Harness initial={{}} />, { colorScheme: "light" });
|
||||
expect(document.querySelector(".ace-github")).not.toBeNull();
|
||||
expect(document.querySelector(".ace-github_dark")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens with the value serialized two-space, nesting intact", () => {
|
||||
renderWithMantine(<Harness initial={{ a: { b: [1, 2] } }} />);
|
||||
expect(getAceText()).toBe(
|
||||
'{\n "a": {\n "b": [\n 1,\n 2\n ]\n }\n}',
|
||||
);
|
||||
});
|
||||
|
||||
it("emits the parsed object — a structured value stays structured", async () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithMantine(<Harness initial={{}} onChange={onChange} />);
|
||||
await setAceText('{"trace":{"id":"x"},"n":3}');
|
||||
expect(onChange).toHaveBeenLastCalledWith({ trace: { id: "x" }, n: 3 });
|
||||
});
|
||||
|
||||
// Also the regression test for the paired-event bug `handleChange` coalesces:
|
||||
// `setValue` is a remove followed by an insert, and acting on the remove
|
||||
// emitted `{}` and wiped `keep` before the insert had landed.
|
||||
it("shows the text as typed while it is invalid and does not emit", async () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithMantine(<Harness initial={{ keep: "me" }} onChange={onChange} />);
|
||||
await setAceText('{"a":');
|
||||
// Displayed verbatim — not re-escaped back through JSON.stringify.
|
||||
expect(getAceText()).toBe('{"a":');
|
||||
expect(
|
||||
screen.getByText("Not valid JSON — changes are not applied"),
|
||||
).toBeInTheDocument();
|
||||
// Nothing emitted, so the caller still holds the last valid object.
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("emitted").textContent).toBe('{"keep":"me"}');
|
||||
});
|
||||
|
||||
it("flags valid JSON that is not an object without emitting", async () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithMantine(<Harness initial={{ keep: "me" }} onChange={onChange} />);
|
||||
await setAceText("[1]");
|
||||
expect(screen.getByText(/Must be a JSON object/)).toBeInTheDocument();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats a cleared editor as the empty object", async () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithMantine(<Harness initial={{ a: 1 }} onChange={onChange} />);
|
||||
await setAceText("");
|
||||
expect(onChange).toHaveBeenLastCalledWith({});
|
||||
expect(screen.queryByText(/Not valid JSON/)).toBeNull();
|
||||
});
|
||||
|
||||
it("re-syncs when the parent's value diverges from the draft", async () => {
|
||||
// An external reset (switching servers, a reloaded catalog) must reach the
|
||||
// editor; an in-progress edit must not be overwritten by what it emitted.
|
||||
function Externally() {
|
||||
const [value, setValue] = useState<StrictJsonObject>({ a: 1 });
|
||||
return (
|
||||
<>
|
||||
<JsonObjectInput
|
||||
ariaLabel={LABEL}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
<button onClick={() => setValue({ b: 2 })}>reset</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
renderWithMantine(<Externally />);
|
||||
await act(async () => {
|
||||
screen.getByRole("button", { name: "reset" }).click();
|
||||
});
|
||||
expect(JSON.parse(getAceText())).toEqual({ b: 2 });
|
||||
});
|
||||
|
||||
it("applies an external value it showed before (A → B → A)", async () => {
|
||||
// Regression: `echoed` used to hold only what this component last emitted,
|
||||
// so returning to a previously-shown value matched the stale entry and was
|
||||
// dismissed as our own echo, stranding the editor on B.
|
||||
function Externally({ value }: { value: StrictJsonObject }) {
|
||||
return (
|
||||
<JsonObjectInput ariaLabel={LABEL} value={value} onChange={() => {}} />
|
||||
);
|
||||
}
|
||||
const { rerender } = renderWithMantine(<Externally value={{ a: 1 }} />);
|
||||
expect(JSON.parse(getAceText())).toEqual({ a: 1 });
|
||||
|
||||
rerender(<Externally value={{ b: 2 }} />);
|
||||
expect(JSON.parse(getAceText())).toEqual({ b: 2 });
|
||||
|
||||
// Back to the first value — a distinct object, same content.
|
||||
rerender(<Externally value={{ a: 1 }} />);
|
||||
expect(JSON.parse(getAceText())).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
describe("wiring to the Input.Wrapper label and error", () => {
|
||||
/** Ace's hidden textarea — the element the wrapper has to point at. */
|
||||
function textarea(): HTMLTextAreaElement {
|
||||
const el = document.querySelector<HTMLTextAreaElement>(
|
||||
"textarea.ace_text-input",
|
||||
);
|
||||
if (!el) throw new Error("Ace text input not mounted");
|
||||
return el;
|
||||
}
|
||||
|
||||
it("gives two instances distinct DOM ids", () => {
|
||||
// react-ace uses `name` as the container id; a fixed value would put
|
||||
// duplicate ids in the document as soon as a second editor renders.
|
||||
renderWithMantine(
|
||||
<>
|
||||
<JsonObjectInput ariaLabel="First" value={{}} onChange={() => {}} />
|
||||
<JsonObjectInput ariaLabel="Second" value={{}} onChange={() => {}} />
|
||||
</>,
|
||||
);
|
||||
const containerIds = Array.from(
|
||||
document.querySelectorAll(".ace_editor"),
|
||||
).map((n) => n.id);
|
||||
const inputIds = Array.from(
|
||||
document.querySelectorAll("textarea.ace_text-input"),
|
||||
).map((n) => n.id);
|
||||
expect(containerIds).toHaveLength(2);
|
||||
expect(new Set([...containerIds, ...inputIds]).size).toBe(4);
|
||||
expect(containerIds.concat(inputIds).every((id) => id !== "")).toBe(true);
|
||||
});
|
||||
|
||||
it("lets the visible label focus the editor", () => {
|
||||
renderWithMantine(
|
||||
<JsonObjectInput
|
||||
label="Request metadata"
|
||||
ariaLabel={LABEL}
|
||||
value={{}}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
// `Input.Wrapper` renders `<label for={id}>`; the editor is only
|
||||
// reachable from it if Ace's textarea carries that same id.
|
||||
const label = screen.getByText("Request metadata");
|
||||
expect(label.getAttribute("for")).toBe(textarea().id);
|
||||
expect(textarea().id).not.toBe("");
|
||||
});
|
||||
|
||||
it("marks the editor invalid and points it at the error text", async () => {
|
||||
renderWithMantine(<Harness initial={{}} />);
|
||||
expect(textarea().getAttribute("aria-invalid")).toBeNull();
|
||||
|
||||
await setAceText("[1]");
|
||||
expect(textarea().getAttribute("aria-invalid")).toBe("true");
|
||||
const describedBy = textarea().getAttribute("aria-describedby");
|
||||
expect(describedBy).not.toBeNull();
|
||||
// The id must resolve to the message actually on screen.
|
||||
expect(document.getElementById(describedBy!)?.textContent).toMatch(
|
||||
/Must be a JSON object/,
|
||||
);
|
||||
});
|
||||
|
||||
it("announces a supplied description, and keeps it when the draft breaks", async () => {
|
||||
function WithDescription() {
|
||||
const [value, setValue] = useState<StrictJsonObject>({});
|
||||
return (
|
||||
<JsonObjectInput
|
||||
ariaLabel={LABEL}
|
||||
description="Sent with every request"
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
renderWithMantine(<WithDescription />);
|
||||
|
||||
const described = () =>
|
||||
(textarea().getAttribute("aria-describedby") ?? "")
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.map((id) => document.getElementById(id)?.textContent);
|
||||
|
||||
// Valid draft: described by the description alone.
|
||||
expect(described()).toEqual(["Sent with every request"]);
|
||||
|
||||
// Invalid draft: the error joins it rather than replacing it — the
|
||||
// description is still true, and dropping it would silently un-describe
|
||||
// the control at the moment it needs explaining most.
|
||||
await setAceText("[1]");
|
||||
expect(described()).toHaveLength(2);
|
||||
expect(described()[0]).toBe("Sent with every request");
|
||||
expect(described()[1]).toMatch(/Must be a JSON object/);
|
||||
});
|
||||
|
||||
it("clears the invalid marking once the text parses again", async () => {
|
||||
renderWithMantine(<Harness initial={{}} />);
|
||||
await setAceText("[1]");
|
||||
expect(textarea().getAttribute("aria-invalid")).toBe("true");
|
||||
await setAceText('{"a":1}');
|
||||
expect(textarea().getAttribute("aria-invalid")).toBeNull();
|
||||
expect(textarea().getAttribute("aria-describedby")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves an in-progress edit alone when the parent echoes it back", async () => {
|
||||
renderWithMantine(<Harness initial={{}} />);
|
||||
// The parent re-renders with the object this text parsed to; the draft must
|
||||
// not be reformatted out from under the cursor.
|
||||
await setAceText('{"a":1}');
|
||||
expect(getAceText()).toBe('{"a":1}');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { Input, useComputedColorScheme } from "@mantine/core";
|
||||
import type { Ace } from "ace-builds";
|
||||
import AceEditor from "react-ace";
|
||||
import ace from "ace-builds/src-noconflict/ace";
|
||||
import "ace-builds/src-noconflict/mode-json";
|
||||
import "ace-builds/src-noconflict/theme-github";
|
||||
import "ace-builds/src-noconflict/theme-github_dark";
|
||||
// `?url` so Vite emits the worker as its own asset and hands back a real URL.
|
||||
// Without it Ace fetches `worker-json.js` from a path that does not exist in a
|
||||
// bundled app, silently loses its annotations, and logs a fetch error.
|
||||
import jsonWorkerUrl from "ace-builds/src-noconflict/worker-json.js?url";
|
||||
import type { StrictJsonObject } from "@inspector/core/json/jsonUtils.js";
|
||||
import { useValueChange } from "../../../hooks/useValueChange";
|
||||
import { parseJsonObjectDraft } from "../../../utils/jsonObjectDraft";
|
||||
|
||||
// Module scope: registering the worker URL is global to Ace and idempotent, so
|
||||
// it must not be repeated per mount.
|
||||
ace.config.setModuleUrl("ace/mode/json_worker", jsonWorkerUrl);
|
||||
|
||||
/** Two-space canonical form — the same shape the catalog file is written in. */
|
||||
function serialize(value: StrictJsonObject): string {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
export interface JsonObjectInputProps {
|
||||
value: StrictJsonObject;
|
||||
onChange: (value: StrictJsonObject) => void;
|
||||
label?: string;
|
||||
description?: string;
|
||||
/**
|
||||
* Accessible name for the editor's text input. Ace labels its hidden
|
||||
* textarea "Cursor at row N" by default, which is a position readout rather
|
||||
* than a name — so this is what makes the control identifiable to a screen
|
||||
* reader, and what `getByLabelText` finds in a test.
|
||||
*/
|
||||
ariaLabel: string;
|
||||
/** Rows shown before the editor grows; it autosizes between these bounds. */
|
||||
minLines?: number;
|
||||
maxLines?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A JSON-object editor: the user types JSON, the parent receives the parsed
|
||||
* object.
|
||||
*
|
||||
* This is the editor for payloads whose *values* may be any JSON, which is why
|
||||
* it exists beside the key/value row editors used for headers and env. `_meta`
|
||||
* is the case that forced it (#1910) — nothing in the MCP spec restricts a
|
||||
* `_meta` value to a string, so a `{ key, value }` row editor cannot express
|
||||
* what the protocol allows.
|
||||
*
|
||||
* Built on Ace rather than a plain textarea because hand-writing JSON in a bare
|
||||
* box is the actual pain: Ace closes a brace when you open one, folds nested
|
||||
* objects, and — via its JSON worker — marks the offending line in the gutter
|
||||
* instead of reporting one message for the whole document.
|
||||
*
|
||||
* The text the user is typing is the source of truth for what is *displayed*;
|
||||
* the parent only ever sees a parsed object. That split is what makes the box
|
||||
* typeable: driving it off the parent's value and stringifying on every render
|
||||
* re-escapes half-typed text once per keystroke, which is the #1928 symptom.
|
||||
* `SchemaJsonField` in `SchemaForm.tsx` carries the same split for the same
|
||||
* reason — see the long comment there.
|
||||
*
|
||||
* While the text is invalid the parent is **not** told, so the last valid
|
||||
* object stands. The alternative — emitting `{}` — would silently discard a
|
||||
* user's configured metadata the moment they typed a stray character, and this
|
||||
* form has no Save button to gate instead: `onChange` writes straight through.
|
||||
* The state is not silent, though: Ace flags the position in the gutter, and
|
||||
* the wrapper repeats the reason underneath.
|
||||
*/
|
||||
export function JsonObjectInput({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
description,
|
||||
ariaLabel,
|
||||
minLines = 6,
|
||||
maxLines = 24,
|
||||
}: JsonObjectInputProps) {
|
||||
const [draft, setDraft] = useState(() => serialize(value));
|
||||
// The canonical JSON of the last value this component emitted.
|
||||
//
|
||||
// The re-sync below has to tell an *external* change apart from the parent
|
||||
// echoing back what we just sent it, and the draft alone cannot: while the
|
||||
// text is invalid it parses to nothing, so every parent change would look
|
||||
// external and overwrite what is being typed.
|
||||
//
|
||||
// That is not hypothetical. Ace's `setValue` — which is what a select-all
|
||||
// paste and any programmatic replace go through — fires *two* change events,
|
||||
// a remove then an insert. The remove leaves an empty document, which is a
|
||||
// valid `{}` and gets emitted; the insert then leaves invalid text. Comparing
|
||||
// against the draft, the `{}` echo reads as an external reset and wipes the
|
||||
// pasted text a keystroke after it lands.
|
||||
const [echoed, setEchoed] = useState(() => serialize(value));
|
||||
// `getInitialValueInEffect: false` so the first render already has the real
|
||||
// scheme; otherwise the editor mounts light and repaints, which is visible.
|
||||
const colorScheme = useComputedColorScheme("light", {
|
||||
getInitialValueInEffect: false,
|
||||
});
|
||||
|
||||
// Re-sync only on a value this component did not produce, so an external
|
||||
// reset (switching servers, a reloaded catalog) reaches the editor while an
|
||||
// in-progress edit is left alone. Compared through canonical JSON because the
|
||||
// parent hands back a fresh object each time.
|
||||
// Text from the most recent change event in the current tick, and whether a
|
||||
// flush is already queued to consume it. Two refs rather than one nullable
|
||||
// one so the flush reads a plain `string` — folding them together would add
|
||||
// a null check that nothing can ever satisfy. See `handleChange`.
|
||||
const pendingTextRef = useRef("");
|
||||
const flushScheduledRef = useRef(false);
|
||||
|
||||
/**
|
||||
* Coalesce the change events Ace fires within one edit, and act on the last.
|
||||
*
|
||||
* A replace — select-all-and-retype, or a paste over a selection — is a
|
||||
* *remove* followed by an *insert*, and Ace fires a change event for each.
|
||||
* Acting on the first would read the momentarily-empty document as the user
|
||||
* clearing the field, emit `{}` to the parent, and so wipe their saved
|
||||
* metadata the instant they selected it. If the replacement text then failed
|
||||
* to parse, `{}` is what they would be left with.
|
||||
*
|
||||
* A microtask is enough because both events are dispatched synchronously
|
||||
* inside the same edit, so the flush sees only the settled text.
|
||||
*/
|
||||
const handleChange = (text: string) => {
|
||||
pendingTextRef.current = text;
|
||||
if (flushScheduledRef.current) return;
|
||||
flushScheduledRef.current = true;
|
||||
queueMicrotask(() => {
|
||||
flushScheduledRef.current = false;
|
||||
const settled = pendingTextRef.current;
|
||||
setDraft(settled);
|
||||
const result = parseJsonObjectDraft(settled);
|
||||
if (!result.ok) return;
|
||||
setEchoed(serialize(result.value));
|
||||
onChange(result.value);
|
||||
});
|
||||
};
|
||||
|
||||
const wrapperId = useId();
|
||||
const editorRef = useRef<Ace.Editor | null>(null);
|
||||
|
||||
const parsed = parseJsonObjectDraft(draft);
|
||||
useValueChange(serialize(value), (next) => {
|
||||
if (next === echoed) return;
|
||||
setDraft(next);
|
||||
// `echoed` has to advance to the value now on screen, not just to what this
|
||||
// component last emitted. Leaving it behind makes an external A → B → A
|
||||
// sequence stick on B: the second A matches the stale `echoed` from before
|
||||
// B arrived and is dismissed as our own echo.
|
||||
setEchoed(next);
|
||||
});
|
||||
|
||||
// `Input.Wrapper` labels and describes a Mantine input through context. Ace
|
||||
// is not one — it renders its own DOM — so the wrapper's label and error node
|
||||
// exist but point at nothing, leaving the control unassociated with the error
|
||||
// a screen reader needs and its visible label inert on click.
|
||||
//
|
||||
// A genuine external-system sync, so an effect rather than `useValueChange`:
|
||||
// it writes to a DOM node React does not own. Giving the textarea the
|
||||
// wrapper's own id is what makes the `<label for>` focus the editor; the
|
||||
// error wiring follows Mantine's `${id}-error` naming.
|
||||
useEffect(() => {
|
||||
const element = editorRef.current?.textInput.getElement();
|
||||
/* v8 ignore next -- the ref is set in `onLoad`, which react-ace calls
|
||||
synchronously on mount, so this effect always sees an element. */
|
||||
if (!element) return;
|
||||
element.id = wrapperId;
|
||||
|
||||
// Both the description and the error are rendered by the wrapper and both
|
||||
// describe this control, so both ids belong here — a supplied description
|
||||
// is otherwise never announced, and it must not be dropped just because the
|
||||
// draft went invalid. Mantine renders each only when its prop is set, so
|
||||
// referencing an absent one would point at nothing.
|
||||
const describedBy = [
|
||||
description === undefined ? null : `${wrapperId}-description`,
|
||||
parsed.ok ? null : `${wrapperId}-error`,
|
||||
].filter((id) => id !== null);
|
||||
|
||||
if (describedBy.length > 0) {
|
||||
element.setAttribute("aria-describedby", describedBy.join(" "));
|
||||
} else {
|
||||
element.removeAttribute("aria-describedby");
|
||||
}
|
||||
|
||||
if (parsed.ok) {
|
||||
element.removeAttribute("aria-invalid");
|
||||
} else {
|
||||
element.setAttribute("aria-invalid", "true");
|
||||
}
|
||||
}, [wrapperId, parsed.ok, description]);
|
||||
|
||||
return (
|
||||
<Input.Wrapper
|
||||
id={wrapperId}
|
||||
label={label}
|
||||
description={description}
|
||||
error={parsed.ok ? undefined : parsed.error}
|
||||
w="100%"
|
||||
>
|
||||
<AceEditor
|
||||
mode="json"
|
||||
theme={colorScheme === "dark" ? "github_dark" : "github"}
|
||||
// react-ace uses `name` as the editor container's DOM id, so a fixed
|
||||
// value would collide the moment two of these render — and it must also
|
||||
// differ from the id given to the textarea below, which is the one the
|
||||
// wrapper's `<label for>` points at.
|
||||
name={`${wrapperId}-editor`}
|
||||
value={draft}
|
||||
onChange={handleChange}
|
||||
width="100%"
|
||||
minLines={minLines}
|
||||
maxLines={maxLines}
|
||||
tabSize={2}
|
||||
showPrintMargin={false}
|
||||
editorProps={{ $blockScrolling: Infinity }}
|
||||
onLoad={(editor) => {
|
||||
editorRef.current = editor;
|
||||
// `textInputAriaLabel` alone is not enough: Ace composes the hidden
|
||||
// textarea's label as "<label>, Cursor at row N" inside
|
||||
// `TextInput.setAriaLabel`, and only recomputes it when the cursor
|
||||
// moves — so an option applied at mount does not reach the DOM until
|
||||
// the user clicks into the editor. Until then the control's only
|
||||
// accessible name is the position readout, which names nothing.
|
||||
// Setting it here and recomputing once is what makes the editor
|
||||
// identifiable from first paint.
|
||||
editor.setOption("textInputAriaLabel", ariaLabel);
|
||||
editor.textInput.setAriaLabel();
|
||||
}}
|
||||
setOptions={{
|
||||
// The JSON worker is what puts a marker on the offending line rather
|
||||
// than one message for the whole document.
|
||||
useWorker: true,
|
||||
// Closes a brace/bracket/quote when you open one, and skips over the
|
||||
// closing one you then type. On by default; named because it is half
|
||||
// of why this component exists.
|
||||
behavioursEnabled: true,
|
||||
// Roving-tabindex arrow-key navigation plus an Esc-to-exit-trap, so
|
||||
// the editor does not swallow Tab for keyboard users.
|
||||
enableKeyboardAccessibility: true,
|
||||
textInputAriaLabel: ariaLabel,
|
||||
showFoldWidgets: true,
|
||||
displayIndentGuides: true,
|
||||
useSoftTabs: true,
|
||||
scrollPastEnd: false,
|
||||
}}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ import {
|
|||
const defaultSettings: InspectorServerSettings = {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
metadata: {},
|
||||
connectionTimeout: 30000,
|
||||
requestTimeout: 60000,
|
||||
taskTtl: 60000,
|
||||
|
|
@ -57,34 +57,9 @@ function InteractiveRender(args: ServerSettingsFormProps) {
|
|||
},
|
||||
});
|
||||
}}
|
||||
onAddMetadata={() => {
|
||||
args.onAddMetadata();
|
||||
updateArgs({
|
||||
settings: {
|
||||
...args.settings,
|
||||
metadata: [...args.settings.metadata, { key: "", value: "" }],
|
||||
},
|
||||
});
|
||||
}}
|
||||
onRemoveMetadata={(index) => {
|
||||
args.onRemoveMetadata(index);
|
||||
updateArgs({
|
||||
settings: {
|
||||
...args.settings,
|
||||
metadata: args.settings.metadata.filter((_, i) => i !== index),
|
||||
},
|
||||
});
|
||||
}}
|
||||
onMetadataChange={(index, key, value) => {
|
||||
args.onMetadataChange(index, key, value);
|
||||
updateArgs({
|
||||
settings: {
|
||||
...args.settings,
|
||||
metadata: args.settings.metadata.map((m, i) =>
|
||||
i === index ? { key, value } : m,
|
||||
),
|
||||
},
|
||||
});
|
||||
onMetadataChange={(metadata) => {
|
||||
args.onMetadataChange(metadata);
|
||||
updateArgs({ settings: { ...args.settings, metadata } });
|
||||
}}
|
||||
onAddEnv={() => {
|
||||
args.onAddEnv();
|
||||
|
|
@ -197,8 +172,6 @@ const meta: Meta<typeof ServerSettingsForm> = {
|
|||
onRemoveEnv: fn(),
|
||||
onEnvChange: fn(),
|
||||
onCwdChange: fn(),
|
||||
onAddMetadata: fn(),
|
||||
onRemoveMetadata: fn(),
|
||||
onMetadataChange: fn(),
|
||||
onTimeoutChange: fn(),
|
||||
onAutoRefreshChange: fn(),
|
||||
|
|
@ -278,10 +251,13 @@ export const AllConfigured: Story = {
|
|||
{ key: "X-Request-Id", value: "req-456" },
|
||||
],
|
||||
env: [],
|
||||
metadata: [
|
||||
{ key: "userId", value: "user-789" },
|
||||
{ key: "sessionId", value: "session-012" },
|
||||
],
|
||||
metadata: {
|
||||
userId: "user-789",
|
||||
sessionId: "session-012",
|
||||
// A structured value — the case a `{ key, value }` row editor could
|
||||
// not express (#1910).
|
||||
client: { name: "inspector", features: ["apps", "tasks"], retries: 3 },
|
||||
},
|
||||
connectionTimeout: 15000,
|
||||
requestTimeout: 45000,
|
||||
taskTtl: 45000,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
|
|||
import { within } from "@testing-library/react";
|
||||
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { getAceText, setAceText } from "../../../test/aceEditor";
|
||||
import {
|
||||
ServerSettingsForm,
|
||||
type ServerSettingsSection,
|
||||
|
|
@ -22,7 +23,7 @@ function clearButtonFor(input: HTMLElement): HTMLElement {
|
|||
const emptySettings: InspectorServerSettings = {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
metadata: {},
|
||||
connectionTimeout: 30000,
|
||||
requestTimeout: 60000,
|
||||
taskTtl: 60000,
|
||||
|
|
@ -33,7 +34,7 @@ const emptySettings: InspectorServerSettings = {
|
|||
const populatedSettings: InspectorServerSettings = {
|
||||
headers: [{ key: "Authorization", value: "Bearer abc" }],
|
||||
env: [],
|
||||
metadata: [{ key: "userId", value: "u-1" }],
|
||||
metadata: { userId: "u-1", tenant: { id: 7, tags: ["a"] } },
|
||||
connectionTimeout: 30000,
|
||||
requestTimeout: 60000,
|
||||
taskTtl: 60000,
|
||||
|
|
@ -63,8 +64,6 @@ const baseHandlers = {
|
|||
onRemoveEnv: vi.fn(),
|
||||
onEnvChange: vi.fn(),
|
||||
onCwdChange: vi.fn(),
|
||||
onAddMetadata: vi.fn(),
|
||||
onRemoveMetadata: vi.fn(),
|
||||
onMetadataChange: vi.fn(),
|
||||
onTimeoutChange: vi.fn(),
|
||||
onAutoRefreshChange: vi.fn(),
|
||||
|
|
@ -239,7 +238,7 @@ describe("ServerSettingsForm", () => {
|
|||
expect(screen.getByText("Log Level per Request")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty hints for headers and metadata when no entries exist", () => {
|
||||
it("shows the empty hint for headers, and an empty JSON object for metadata", () => {
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
|
|
@ -250,9 +249,9 @@ describe("ServerSettingsForm", () => {
|
|||
expect(
|
||||
screen.getByText("No custom headers configured"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("No request metadata configured"),
|
||||
).toBeInTheDocument();
|
||||
// Metadata has no rows to be absent — the editor is always present, and
|
||||
// "none configured" is spelled `{}` in it.
|
||||
expect(getAceText()).toBe("{}");
|
||||
});
|
||||
|
||||
it("invokes onAddHeader when + Add Header is clicked", async () => {
|
||||
|
|
@ -270,21 +269,6 @@ describe("ServerSettingsForm", () => {
|
|||
expect(onAddHeader).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("invokes onAddMetadata when + Add Metadata is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAddMetadata = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
onAddMetadata={onAddMetadata}
|
||||
settings={emptySettings}
|
||||
expandedSections={["metadata"]}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "+ Add Metadata" }));
|
||||
expect(onAddMetadata).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("invokes onHeaderChange when typing in header value input", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onHeaderChange = vi.fn();
|
||||
|
|
@ -340,29 +324,34 @@ describe("ServerSettingsForm", () => {
|
|||
expect(onRemoveHeader).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("invokes onMetadataChange and onRemoveMetadata for metadata rows", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMetadataChange = vi.fn();
|
||||
const onRemoveMetadata = vi.fn();
|
||||
it("renders the configured metadata as formatted JSON, nested values included", () => {
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
onMetadataChange={onMetadataChange}
|
||||
onRemoveMetadata={onRemoveMetadata}
|
||||
settings={populatedSettings}
|
||||
expandedSections={["metadata"]}
|
||||
/>,
|
||||
);
|
||||
const valueInput = screen.getByDisplayValue("u-1");
|
||||
await user.type(valueInput, "Z");
|
||||
expect(onMetadataChange).toHaveBeenCalled();
|
||||
// The point of #1910: a nested object survives into the editor instead of
|
||||
// being flattened into a `{ key, value }` string row.
|
||||
expect(JSON.parse(getAceText())).toEqual({
|
||||
userId: "u-1",
|
||||
tenant: { id: 7, tags: ["a"] },
|
||||
});
|
||||
});
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Remove metadata entry, userId, row 1",
|
||||
}),
|
||||
it("reports the whole edited object through onMetadataChange", async () => {
|
||||
const onMetadataChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
onMetadataChange={onMetadataChange}
|
||||
settings={emptySettings}
|
||||
expandedSections={["metadata"]}
|
||||
/>,
|
||||
);
|
||||
expect(onRemoveMetadata).toHaveBeenCalledWith(0);
|
||||
await setAceText('{"n":[1,2]}');
|
||||
expect(onMetadataChange).toHaveBeenLastCalledWith({ n: [1, 2] });
|
||||
});
|
||||
|
||||
it("invokes onTimeoutChange when typing in connection timeout", async () => {
|
||||
|
|
@ -1470,23 +1459,6 @@ describe("ServerSettingsForm", () => {
|
|||
expect(onClearStoredOAuth).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clears a metadata value via its Clear button (onMetadataChange with empty value)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMetadataChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
onMetadataChange={onMetadataChange}
|
||||
settings={populatedSettings}
|
||||
expandedSections={["metadata"]}
|
||||
/>,
|
||||
);
|
||||
// populatedSettings has one metadata { key: "userId", value: "u-1" }
|
||||
const valueInput = screen.getByDisplayValue("u-1");
|
||||
await user.click(clearButtonFor(valueInput));
|
||||
expect(onMetadataChange).toHaveBeenCalledWith(0, "userId", "");
|
||||
});
|
||||
|
||||
it("omits the Clear buttons for an empty header key/value row", () => {
|
||||
// A row whose key and value are both empty renders no Clear button in
|
||||
// either field (the null branch of `item.key ?` / `item.value ?`).
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Code,
|
||||
Flex,
|
||||
Group,
|
||||
NumberInput,
|
||||
|
|
@ -13,11 +14,13 @@ import {
|
|||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { ClearButton } from "../../elements/ClearButton/ClearButton";
|
||||
import { JsonObjectInput } from "../../elements/JsonObjectInput/JsonObjectInput";
|
||||
import type { ProtocolEra } from "@modelcontextprotocol/client";
|
||||
import type {
|
||||
InspectorServerSettings,
|
||||
ModernLogLevel,
|
||||
OAuthSettings,
|
||||
RequestMetadata,
|
||||
ServerProtocolEra,
|
||||
ServerType,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
|
|
@ -78,9 +81,12 @@ export interface ServerSettingsFormProps {
|
|||
onRemoveEnv: (index: number) => void;
|
||||
onEnvChange: (index: number, key: string, value: string) => void;
|
||||
onCwdChange: (value: string) => void;
|
||||
onAddMetadata: () => void;
|
||||
onRemoveMetadata: (index: number) => void;
|
||||
onMetadataChange: (index: number, key: string, value: string) => void;
|
||||
/**
|
||||
* Replace the whole default-`_meta` payload. Not the `(index, key, value)`
|
||||
* row callback the header/env editors use — metadata is edited as a JSON
|
||||
* object, since a `_meta` value may be any JSON (#1910).
|
||||
*/
|
||||
onMetadataChange: (metadata: RequestMetadata) => void;
|
||||
onTimeoutChange: (
|
||||
field: "connectionTimeout" | "requestTimeout" | "taskTtl",
|
||||
value: number,
|
||||
|
|
@ -434,8 +440,6 @@ export function ServerSettingsForm({
|
|||
onRemoveEnv,
|
||||
onEnvChange,
|
||||
onCwdChange,
|
||||
onAddMetadata,
|
||||
onRemoveMetadata,
|
||||
onMetadataChange,
|
||||
onTimeoutChange,
|
||||
onAutoRefreshChange,
|
||||
|
|
@ -724,22 +728,23 @@ export function ServerSettingsForm({
|
|||
<Accordion.Control>Request Metadata</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<HintText>
|
||||
Metadata sent with every MCP request (included in _meta field)
|
||||
</HintText>
|
||||
<AddButton onClick={onAddMetadata}>+ Add Metadata</AddButton>
|
||||
</Group>
|
||||
{settings.metadata.length === 0 ? (
|
||||
<EmptyHint>No request metadata configured</EmptyHint>
|
||||
) : (
|
||||
<KeyValueRows
|
||||
items={settings.metadata}
|
||||
entityLabel="metadata entry"
|
||||
onChange={onMetadataChange}
|
||||
onRemove={onRemoveMetadata}
|
||||
/>
|
||||
)}
|
||||
<HintText>
|
||||
Metadata merged into the <Code>_meta</Code> field of every MCP
|
||||
request to this server, as a JSON object. Values may be any JSON —
|
||||
objects, arrays, numbers, booleans, <Code>null</Code> — not just
|
||||
strings. Leave it as <Code>{"{}"}</Code> to send none. The keys
|
||||
the protocol reserves keep their own shape:{" "}
|
||||
<Code>progressToken</Code> must be a string or a safe integer (one
|
||||
JavaScript can represent exactly), and{" "}
|
||||
<Code>io.modelcontextprotocol/related-task</Code> an object with a
|
||||
string <Code>taskId</Code>. A reserved key that does not match is
|
||||
dropped from the request rather than sent.
|
||||
</HintText>
|
||||
<JsonObjectInput
|
||||
ariaLabel="Request metadata JSON"
|
||||
value={settings.metadata}
|
||||
onChange={onMetadataChange}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const initialSettings: InspectorServerSettings = {
|
|||
{ key: "X-Request-Id", value: "req-456" },
|
||||
],
|
||||
env: [],
|
||||
metadata: [{ key: "userId", value: "user-789" }],
|
||||
metadata: { userId: "user-789", client: { name: "inspector" } },
|
||||
connectionTimeout: 30000,
|
||||
requestTimeout: 60000,
|
||||
taskTtl: 60000,
|
||||
|
|
@ -74,7 +74,7 @@ export const EmptySettings: Story = {
|
|||
settings: {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
metadata: {},
|
||||
connectionTimeout: 30000,
|
||||
requestTimeout: 60000,
|
||||
taskTtl: 60000,
|
||||
|
|
@ -96,7 +96,7 @@ export const StdioServer: Story = {
|
|||
{ key: "API_KEY", value: "abc-123" },
|
||||
{ key: "LOG_LEVEL", value: "debug" },
|
||||
],
|
||||
metadata: [],
|
||||
metadata: {},
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 60000,
|
||||
|
|
|
|||
|
|
@ -2,12 +2,13 @@ import { describe, it, expect, vi } from "vitest";
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { setAceText } from "../../../test/aceEditor";
|
||||
import { ServerSettingsModal } from "./ServerSettingsModal";
|
||||
|
||||
const initialSettings: InspectorServerSettings = {
|
||||
headers: [{ key: "Authorization", value: "Bearer abc" }],
|
||||
env: [],
|
||||
metadata: [{ key: "userId", value: "u-1" }],
|
||||
metadata: { userId: "u-1" },
|
||||
connectionTimeout: 30000,
|
||||
requestTimeout: 60000,
|
||||
taskTtl: 60000,
|
||||
|
|
@ -21,7 +22,7 @@ const initialSettings: InspectorServerSettings = {
|
|||
const emptySettings: InspectorServerSettings = {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
metadata: {},
|
||||
connectionTimeout: 30000,
|
||||
requestTimeout: 60000,
|
||||
taskTtl: 60000,
|
||||
|
|
@ -310,76 +311,6 @@ describe("ServerSettingsModal", () => {
|
|||
expect(lastCall.headers[0].value).toContain("Bearer abc");
|
||||
});
|
||||
|
||||
it("calls onSettingsChange when adding metadata after expanding the section", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSettingsChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsModal
|
||||
opened
|
||||
settings={emptySettings}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Request Metadata" }));
|
||||
await user.click(screen.getByRole("button", { name: "+ Add Metadata" }));
|
||||
expect(onSettingsChange).toHaveBeenCalledWith({
|
||||
...emptySettings,
|
||||
metadata: [{ key: "", value: "" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onSettingsChange when removing metadata", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSettingsChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsModal
|
||||
opened
|
||||
settings={initialSettings}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Request Metadata" }));
|
||||
// Both the header and metadata rows have a remove button; each is named
|
||||
// for the row it belongs to, so no positional guess is needed.
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Remove metadata entry, userId, row 1",
|
||||
}),
|
||||
);
|
||||
expect(onSettingsChange).toHaveBeenCalledWith({
|
||||
...initialSettings,
|
||||
metadata: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onSettingsChange when typing in metadata key/value", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSettingsChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsModal
|
||||
opened
|
||||
settings={initialSettings}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Request Metadata" }));
|
||||
const valueInput = screen.getByDisplayValue("u-1");
|
||||
await user.type(valueInput, "9");
|
||||
expect(onSettingsChange).toHaveBeenCalled();
|
||||
const lastCall =
|
||||
onSettingsChange.mock.calls[onSettingsChange.mock.calls.length - 1][0];
|
||||
expect(lastCall.metadata[0].key).toBe("userId");
|
||||
});
|
||||
|
||||
it("calls onSettingsChange when changing timeouts", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSettingsChange = vi.fn();
|
||||
|
|
@ -703,20 +634,13 @@ describe("ServerSettingsModal", () => {
|
|||
expect(lastCall.headers[1]).toEqual({ key: "B", value: "2" });
|
||||
});
|
||||
|
||||
it("preserves sibling rows when editing one of several metadata entries", async () => {
|
||||
it("replaces the whole metadata object when the JSON editor changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSettingsChange = vi.fn();
|
||||
const twoMeta: InspectorServerSettings = {
|
||||
...emptySettings,
|
||||
metadata: [
|
||||
{ key: "m1", value: "v1" },
|
||||
{ key: "m2", value: "v2" },
|
||||
],
|
||||
};
|
||||
renderWithMantine(
|
||||
<ServerSettingsModal
|
||||
opened
|
||||
settings={twoMeta}
|
||||
settings={emptySettings}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
|
|
@ -724,9 +648,13 @@ describe("ServerSettingsModal", () => {
|
|||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Request Metadata" }));
|
||||
await user.type(screen.getByDisplayValue("v1"), "x");
|
||||
await setAceText('{"trace":{"id":"abc"}}');
|
||||
const lastCall = onSettingsChange.mock.calls.at(-1)?.[0];
|
||||
expect(lastCall.metadata[1]).toEqual({ key: "m2", value: "v2" });
|
||||
// The nested object arrives as an object, not a stringified one — the
|
||||
// whole point of #1910.
|
||||
expect(lastCall.metadata).toEqual({ trace: { id: "abc" } });
|
||||
// Every other setting is carried through untouched.
|
||||
expect(lastCall.headers).toEqual(emptySettings.headers);
|
||||
});
|
||||
|
||||
it("preserves sibling rows when editing one of several roots", async () => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
InspectorServerSettings,
|
||||
ModernLogLevel,
|
||||
OAuthSettings,
|
||||
RequestMetadata,
|
||||
ServerProtocolEra,
|
||||
ServerType,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
|
|
@ -155,24 +156,7 @@ export function ServerSettingsModal({
|
|||
onSettingsChange({ ...settings, cwd: value });
|
||||
}
|
||||
|
||||
function handleAddMetadata() {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
metadata: [...settings.metadata, { key: "", value: "" }],
|
||||
});
|
||||
}
|
||||
|
||||
function handleRemoveMetadata(index: number) {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
metadata: settings.metadata.filter((_, i) => i !== index),
|
||||
});
|
||||
}
|
||||
|
||||
function handleMetadataChange(index: number, key: string, value: string) {
|
||||
const metadata = settings.metadata.map((m, i) =>
|
||||
i === index ? { key, value } : m,
|
||||
);
|
||||
function handleMetadataChange(metadata: RequestMetadata) {
|
||||
onSettingsChange({ ...settings, metadata });
|
||||
}
|
||||
|
||||
|
|
@ -303,8 +287,6 @@ export function ServerSettingsModal({
|
|||
onRemoveEnv={handleRemoveEnv}
|
||||
onEnvChange={handleEnvChange}
|
||||
onCwdChange={handleCwdChange}
|
||||
onAddMetadata={handleAddMetadata}
|
||||
onRemoveMetadata={handleRemoveMetadata}
|
||||
onMetadataChange={handleMetadataChange}
|
||||
onTimeoutChange={handleTimeoutChange}
|
||||
onAutoRefreshChange={handleAutoRefreshChange}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
} from "./useImportClientConfig";
|
||||
import type { ImportSourceResult } from "@inspector/core/mcp/import/index.js";
|
||||
import type { MCPConfig } from "@inspector/core/mcp/types.js";
|
||||
import { ServerListReloadError } from "@inspector/core/react/useServers.js";
|
||||
|
||||
function config(...ids: string[]): MCPConfig {
|
||||
const mcpServers: MCPConfig["mcpServers"] = {};
|
||||
|
|
@ -207,6 +208,31 @@ describe("useImportClientConfig", () => {
|
|||
expect(outcome.detail).toBe("add failed");
|
||||
});
|
||||
|
||||
it("reports a reload failure as imported-with-a-warning, not failed (#1914)", async () => {
|
||||
// A `ServerListReloadError` rejects *after* the write landed, so the entry
|
||||
// really was added. A red "Failed" badge would contradict the error's own
|
||||
// message and invite a retry that then collides with the row it claims
|
||||
// wasn't created. The reason still shows, as `detail`.
|
||||
const onAddServer = vi.fn(async () => {
|
||||
throw new ServerListReloadError(
|
||||
"The server was added, but the server list could not be reloaded: disk full",
|
||||
);
|
||||
});
|
||||
const { result } = setup({
|
||||
onFetchSource: vi.fn(async () => foundSource(config("alpha"))),
|
||||
onAddServer,
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.pickSource("claude");
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.runImport();
|
||||
});
|
||||
const outcome = result.current.outcomes[0];
|
||||
expect(outcome.status).toBe("added");
|
||||
expect(outcome.detail).toMatch(/could not be reloaded: disk full/);
|
||||
});
|
||||
|
||||
it("resolves conflicts by overwrite, skip, and rename", async () => {
|
||||
const onAddServer = vi.fn(async () => {});
|
||||
const onUpdateServer = vi.fn(async () => {});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
type ImportSourceResult,
|
||||
} from "@inspector/core/mcp/import/index.js";
|
||||
import { validateStoreId } from "@inspector/core/storage/store-id.js";
|
||||
import { ServerListReloadError } from "@inspector/core/react/useServers.js";
|
||||
import type { MCPConfig, MCPServerConfig } from "@inspector/core/mcp/types.js";
|
||||
|
||||
export type ImportPhase = "select" | "loading" | "review" | "summary";
|
||||
|
|
@ -198,11 +199,17 @@ export function useImportClientConfig({
|
|||
await write();
|
||||
return { id, status: outcome };
|
||||
} catch (err) {
|
||||
return {
|
||||
id,
|
||||
status: "failed",
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
// A `ServerListReloadError` means the write landed and only reading the
|
||||
// list back failed, so this entry really was imported. Reporting it as
|
||||
// `failed` would contradict the error's own message and invite a retry
|
||||
// that then collides with the row it says wasn't created (#1914 r3).
|
||||
// The status is the truth about the write; `detail` renders in red
|
||||
// beneath it either way, so the reload failure is still visible.
|
||||
if (err instanceof ServerListReloadError) {
|
||||
return { id, status: outcome, detail };
|
||||
}
|
||||
return { id, status: "failed", detail };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const settings = (
|
|||
): InspectorServerSettings => ({
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
metadata: {},
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue