Merge remote-tracking branch 'origin/v2/main' into v2/chore/milestone-merge-v2.3.0
Signed-off-by: cliffhall <cliff@futurescale.com> # Conflicts: # package-lock.json # package.json
This commit is contained in:
commit
3719151081
127 changed files with 18171 additions and 2062 deletions
|
|
@ -162,7 +162,9 @@ The dev/prod backend guards every `/api/*` route with `x-mcp-remote-auth: Bearer
|
|||
|
||||
## Host binding & the origin allow-list
|
||||
|
||||
Both the prod backend (`server/web-server-config.ts`) and the dev Vite server (`vite.config.ts`) resolve their bind host through one shared guard, `server/resolve-bind-host.ts`. It binds `localhost` by default and **refuses an all-interfaces host** (`0.0.0.0`, `::`, empty, or any equivalent spelling — `0`, `0x0`, `0.0`, `::0`, `::ffff:0.0.0.0`, … are all folded to the wildcard and refused) — which would expose the process-spawning backend to the whole network, the exposure DNS-rebinding attacks target — unless `DANGEROUSLY_BIND_ALL_INTERFACES=true` is set. The Docker image sets that flag (a container must bind `0.0.0.0` to be reachable through `-p`); a bare `HOST=0.0.0.0` anywhere else exits with an actionable error.
|
||||
Both the prod backend (`server/web-server-config.ts`) and the dev Vite server (`vite.config.ts`) resolve their bind host through one shared guard, `server/resolve-bind-host.ts`. It binds **`127.0.0.1`** by default and **refuses an all-interfaces host** (`0.0.0.0`, `::`, empty, or any equivalent spelling — `0`, `0x0`, `0.0`, `::0`, `::ffff:0.0.0.0`, … are all folded to the wildcard and refused) — which would expose the process-spawning backend to the whole network, the exposure DNS-rebinding attacks target — unless `DANGEROUSLY_BIND_ALL_INTERFACES=true` is set. The Docker image sets that flag (a container must bind `0.0.0.0` to be reachable through `-p`); a bare `HOST=0.0.0.0` anywhere else exits with an actionable error.
|
||||
|
||||
**The default is the IPv4 loopback _address_, deliberately not the name `localhost`** (#1951). `server.listen(port, host)` resolves a name through `dns.lookup` and binds the **single** address it gets back; since Node 17 the default result order is `verbatim`, so on a glibc Linux host — whose `/etc/hosts` maps `localhost` to both `127.0.0.1` and `::1` — that first address is `::1`. `HOST=localhost` there binds IPv6 loopback **only** and refuses every IPv4 client. On a desktop this is invisible (the browser resolves `localhost` the same way and lands on `::1` too); it breaks wherever the connection is made by something that pins `127.0.0.1` — a VS Code dev container's port forwarder, `ssh -L 6274:127.0.0.1:6274`, a container healthcheck. Binding the address takes the resolver out of the decision. Browsing to `http://localhost:PORT` still works, since browsers fall back across address families and the loopback allow-list covers all three forms. An explicit `HOST=localhost` is still honored as typed — the single-family bind is then your choice.
|
||||
|
||||
The backend's `/api/*` routes also enforce an **origin allow-list** (`allowedOrigins`) as DNS-rebinding protection. When left to default on a loopback host, it expands to all three interchangeable loopback origin forms for the port — `http://localhost:PORT`, `http://127.0.0.1:PORT`, and `http://[::1]:PORT` — because `localhost` resolves to either IPv4 or IPv6 loopback and Node/Vite may bind the IPv6 form, so the browser can legitimately arrive at `http://[::1]:PORT`. Set `ALLOWED_ORIGINS` (comma-separated) to override; entries are canonicalized (`new URL(o).origin`), so a trailing slash / uppercase host / explicit `:80` still match. **Each entry must include the scheme** — `http://localhost:6274`, not `localhost:6274` (a scheme-less value is dropped with a warning). `ALLOWED_ORIGINS` **replaces** the default list (it does not merge), so **list every origin you'll browse from, including the loopback forms** you still want (`http://localhost:PORT`, `http://127.0.0.1:PORT`, `http://[::1]:PORT`) — otherwise local access stops working. A blank `ALLOWED_ORIGINS` does **not** disable the check — it falls back to the default (fail closed); there is no env knob to turn origin validation off.
|
||||
|
||||
|
|
@ -176,7 +178,7 @@ The guard blocks only the **wildcard** all-interfaces addresses. Binding a **spe
|
|||
|
||||
The bind-host guard and the `ALLOWED_ORIGINS` allow-list apply to both the prod server and `--dev`. Note that in **`--dev`** the Vite dev server _additionally_ enforces its own `server.allowedHosts` Host-header check, whose default accepts loopback and IP-literal hosts. The host you **bind** is auto-allowed (Vite adds the resolved `server.host` — which this config sets from `HOST` — to the allow-list), so `HOST=<hostname>` works out of the box under `--dev` too. What needs an explicit `server.allowedHosts` entry is reaching the dev server at a **different** name than the one bound — e.g. a wildcard bind reached by hostname, or a reverse-proxy domain. For those, prefer the prod server (`mcp-inspector --web`) or add the host to `server.allowedHosts`.
|
||||
|
||||
**MCP Apps caveats.** The MCP Apps sandbox runs on a **separate** port (`MCP_SANDBOX_PORT`, dynamic by default). For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — pin it with `MCP_SANDBOX_PORT` and expose/forward it too (the Docker image `EXPOSE`s only `6274`). (Under a `0.0.0.0` wildcard bind the sandbox URL is advertised as `localhost`, which is reachable — a wildcard bind serves loopback — so only the port needs handling.) Also note the sandbox iframe is gated by a `frame-ancestors` CSP, and **a bracketed IPv6 literal is not a valid CSP host-source** — so MCP Apps requires browsing the app at a name or IPv4 (`localhost`, `127.0.0.1`, a hostname, a LAN IPv4), **not** a bare `http://[::1]:…` address. Finally, the sandbox URL is always `http://` — so **behind TLS** (an `https://` app page) the browser blocks the `http://…/sandbox` iframe as mixed content and MCP Apps can't render; the Apps tab needs a plain-`http` app origin today.
|
||||
**MCP Apps caveats.** The MCP Apps sandbox runs on a **separate** port — `MCP_SANDBOX_PORT`, defaulting to a fixed **`6275`** (#2008; it was OS-assigned before, which meant it changed every run and so could never be named in a `forwardPorts` / `-p` / tunnel config written ahead of time). For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — expose/forward `6275` alongside `6274`, or set `MCP_SANDBOX_PORT` to pick another. If the port is already taken the sandbox falls back to an OS-assigned one and warns, so a second Inspector still gets a working Apps tab locally — but the forwarded port is then wrong, which is what the warning tells you. (Under a `0.0.0.0` wildcard bind the sandbox URL is advertised as `localhost`, which is reachable — a wildcard bind serves loopback — so only the port needs handling.) Also note the sandbox iframe is gated by a `frame-ancestors` CSP, and **a bracketed IPv6 literal is not a valid CSP host-source** — so MCP Apps requires browsing the app at a name or IPv4 (`localhost`, `127.0.0.1`, a hostname, a LAN IPv4), **not** a bare `http://[::1]:…` address. Finally, the sandbox URL is always `http://` — so **behind TLS** (an `https://` app page) the browser blocks the `http://…/sandbox` iframe as mixed content and MCP Apps can't render; the Apps tab needs a plain-`http` app origin today.
|
||||
|
||||
In every case, exposing the Inspector beyond loopback also means anyone who can reach it can drive its backend — keep authentication on (do **not** set `DANGEROUSLY_OMIT_AUTH`) and prefer a specific bind address over the wildcard.
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,12 @@ import tseslint from "typescript-eslint";
|
|||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(["dist", "storybook-static", "coverage"]),
|
||||
// Generated output, none of it first-party source. Web is the only client
|
||||
// that emits *two* bundles — `dist` (the Vite SPA) and `build` (the tsup
|
||||
// prod-server runner, which vendors ~1.2MB of `undici`) — and only the first
|
||||
// was originally listed, so `lint` reported a warning from inside undici's
|
||||
// own source that nobody could act on (#2043).
|
||||
globalIgnores(["build", "dist", "storybook-static", "coverage"]),
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
|
|
@ -31,5 +36,32 @@ export default defineConfig([
|
|||
"react-refresh/only-export-components": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
// Type-aware pass for `no-floating-promises` (#1959). The rule needs type
|
||||
// information, which `tseslint.configs.recommended` does not provide, and
|
||||
// the parser needs a project that literally contains the linted file — so
|
||||
// all four leaf projects are listed (`tsconfig.json` itself is a
|
||||
// solution file with `files: []`, so it contains nothing).
|
||||
//
|
||||
// `.d.ts` is excluded: `vitest.shims.d.ts` is pulled in through a `types`
|
||||
// reference rather than an `include`, so no project owns it, and a
|
||||
// declaration file cannot float a promise anyway.
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
ignores: ["**/*.d.ts"],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: [
|
||||
"./tsconfig.app.json",
|
||||
"./tsconfig.node.json",
|
||||
"./tsconfig.storybook.json",
|
||||
"./tsconfig.test.json",
|
||||
],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
},
|
||||
},
|
||||
...storybook.configs["flat/recommended"],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -26,21 +26,46 @@ function isEnabled(value: string | undefined): boolean {
|
|||
return v === "true" || v === "1";
|
||||
}
|
||||
|
||||
/**
|
||||
* The default bind host: the IPv4 loopback **address**, deliberately not the
|
||||
* name `localhost`.
|
||||
*
|
||||
* `server.listen(port, host)` resolves a name through `dns.lookup` and binds the
|
||||
* **single** address it gets back. Since Node 17 the default result order is
|
||||
* `verbatim`, so Node takes whatever the resolver returns first rather than
|
||||
* preferring IPv4 — and on a glibc Linux host with the stock `/etc/hosts`
|
||||
* (`127.0.0.1 localhost` *and* `::1 localhost`) that is `::1`. `HOST=localhost`
|
||||
* therefore binds IPv6 loopback **only**, and every IPv4 client is refused.
|
||||
*
|
||||
* That is invisible on a desktop, where the browser resolves `localhost` the
|
||||
* same way and lands on `::1` too. It breaks wherever the connection is made by
|
||||
* something that pins IPv4 `127.0.0.1` instead — a VS Code dev container's port
|
||||
* forwarder (#1951), an `ssh -L …:127.0.0.1:…` tunnel, a container healthcheck.
|
||||
* Binding the address directly removes the resolver from the decision, and the
|
||||
* loopback origin allow-list already covers `localhost`, `127.0.0.1`, and
|
||||
* `[::1]` alike, so browsing to `http://localhost:PORT` still works (browsers
|
||||
* fall back across address families; a Node `listen` does not).
|
||||
*/
|
||||
export const DEFAULT_BIND_HOST = "127.0.0.1";
|
||||
|
||||
/**
|
||||
* Resolve the bind hostname from `env` (default `process.env`), defaulting to
|
||||
* `localhost`. Refuses an all-interfaces host (`0.0.0.0` / `::` / empty / their
|
||||
* legacy spellings) unless {@link BIND_ALL_INTERFACES_ENV} is explicitly
|
||||
* enabled — the published Docker image sets it, since a container must bind
|
||||
* `0.0.0.0` to be reachable through `-p`. Throws (fail fast, loudly) rather than
|
||||
* silently binding wide open. The returned value is trimmed and de-bracketed
|
||||
* (an IPv6 literal is returned bare, e.g. `HOST=[::1]` → `::1`) so detection,
|
||||
* `listen()`, and the origin list all consume the same value; `formatHostForUrl`
|
||||
* re-adds the brackets wherever a URL is built.
|
||||
* {@link DEFAULT_BIND_HOST}. Refuses an all-interfaces host (`0.0.0.0` / `::` /
|
||||
* empty / their legacy spellings) unless {@link BIND_ALL_INTERFACES_ENV} is
|
||||
* explicitly enabled — the published Docker image sets it, since a container
|
||||
* must bind `0.0.0.0` to be reachable through `-p`. Throws (fail fast, loudly)
|
||||
* rather than silently binding wide open. The returned value is trimmed and
|
||||
* de-bracketed (an IPv6 literal is returned bare, e.g. `HOST=[::1]` → `::1`) so
|
||||
* detection, `listen()`, and the origin list all consume the same value;
|
||||
* `formatHostForUrl` re-adds the brackets wherever a URL is built.
|
||||
*
|
||||
* An explicit `HOST=localhost` is honored as typed — the single-family bind
|
||||
* described on {@link DEFAULT_BIND_HOST} is then the caller's choice, not ours.
|
||||
*/
|
||||
export function resolveBindHostname(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
const host = (env.HOST ?? "localhost").trim();
|
||||
const host = (env.HOST ?? DEFAULT_BIND_HOST).trim();
|
||||
if (isAllInterfacesHost(host) && !isEnabled(env[BIND_ALL_INTERFACES_ENV])) {
|
||||
// Show the resolved address when it differs from the typed spelling — the
|
||||
// guard now catches forms the resolver folds to the wildcard (a fullwidth
|
||||
|
|
|
|||
|
|
@ -11,13 +11,14 @@ import {
|
|||
canonicalUrlHost,
|
||||
isAllInterfacesHost,
|
||||
} from "../../../core/node/hostUrl.ts";
|
||||
import { DEFAULT_BIND_HOST } from "./resolve-bind-host.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export interface SandboxControllerOptions {
|
||||
/** Port to bind (0 = dynamic). */
|
||||
port: number;
|
||||
/** Host to bind (default localhost). */
|
||||
/** Host to bind (default {@link DEFAULT_BIND_HOST}). */
|
||||
host?: string;
|
||||
/**
|
||||
* The backend's origin allow-list (the embedder origins). Used to build the
|
||||
|
|
@ -95,10 +96,28 @@ function parseListenPort(raw: string | undefined): number | undefined {
|
|||
}
|
||||
|
||||
/**
|
||||
* Resolve sandbox port from env: MCP_SANDBOX_PORT → SERVER_PORT → 0 (dynamic).
|
||||
* An invalid value falls through rather than crashing the boot; a set-but-invalid
|
||||
* MCP_SANDBOX_PORT (the dedicated knob) is warned so the fall-through isn't
|
||||
* silent — matching the warn-and-drop precedent for ALLOWED_ORIGINS.
|
||||
* The default sandbox listen port — **fixed**, deliberately not `0`.
|
||||
*
|
||||
* A `0` default lets the OS assign, which is fine on a desktop (the browser
|
||||
* learns the port from `/api/config`) but unusable anywhere the port has to be
|
||||
* declared *before* the process starts: a dev container's `forwardPorts`, a
|
||||
* `docker run -p`, a k8s service, an SSH tunnel. The port changed on every run,
|
||||
* so it could never be named in that config and the Apps tab was unreachable
|
||||
* (#2008). `CLIENT_PORT` has always defaulted to a fixed `6274` for the same
|
||||
* reason; this is its sandbox counterpart.
|
||||
*
|
||||
* `6275` sits next to the web port so the pair is easy to forward together. It
|
||||
* is not a v1 carry-over — v2 has no proxy server on it.
|
||||
*/
|
||||
export const DEFAULT_SANDBOX_PORT = 6275;
|
||||
|
||||
/**
|
||||
* Resolve sandbox port from env: MCP_SANDBOX_PORT → SERVER_PORT →
|
||||
* {@link DEFAULT_SANDBOX_PORT}. An invalid value falls through rather than
|
||||
* crashing the boot; a set-but-invalid MCP_SANDBOX_PORT (the dedicated knob) is
|
||||
* warned so the fall-through isn't silent — matching the warn-and-drop
|
||||
* precedent for ALLOWED_ORIGINS. An explicit `0` is still honored as "let the
|
||||
* OS assign" ({@link parseListenPort} accepts it).
|
||||
*/
|
||||
export function resolveSandboxPort(): number {
|
||||
const fromSandbox = parseListenPort(process.env.MCP_SANDBOX_PORT);
|
||||
|
|
@ -107,13 +126,21 @@ export function resolveSandboxPort(): number {
|
|||
`Ignoring invalid MCP_SANDBOX_PORT="${process.env.MCP_SANDBOX_PORT}" (need an integer 0–65535); falling back.`,
|
||||
);
|
||||
}
|
||||
return fromSandbox ?? parseListenPort(process.env.SERVER_PORT) ?? 0;
|
||||
return (
|
||||
fromSandbox ??
|
||||
parseListenPort(process.env.SERVER_PORT) ??
|
||||
DEFAULT_SANDBOX_PORT
|
||||
);
|
||||
}
|
||||
|
||||
export function createSandboxController(
|
||||
options: SandboxControllerOptions,
|
||||
): SandboxController {
|
||||
const { port, host = "localhost", allowedOrigins } = options;
|
||||
// Defaulted to the same address the web server binds, never the name
|
||||
// `localhost` — a name resolves to one address family and would reintroduce
|
||||
// the #1951 split (web on IPv4, sandbox on IPv6) for any future call site
|
||||
// that omits `host`. Both call sites pass `config.sandboxHost` today.
|
||||
const { port, host = DEFAULT_BIND_HOST, allowedOrigins } = options;
|
||||
let server: Server | null = null;
|
||||
let sandboxUrl: string | null = null;
|
||||
|
||||
|
|
@ -177,8 +204,28 @@ export function createSandboxController(
|
|||
});
|
||||
res.end(sandboxHtml);
|
||||
});
|
||||
// A taken port used to mean "no sandbox at all", which was tolerable
|
||||
// only because the default was dynamic and therefore never collided.
|
||||
// Now that it's fixed (see DEFAULT_SANDBOX_PORT), a second Inspector —
|
||||
// or anything else already on 6275 — would take the Apps tab down, so
|
||||
// retry once on an OS-assigned port. The retry is announced loudly:
|
||||
// whoever pinned MCP_SANDBOX_PORT to forward it needs to know the pin
|
||||
// didn't take, and a silently-moved port is exactly the failure this
|
||||
// default was introduced to fix.
|
||||
let retriedDynamic = false;
|
||||
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
if (!retriedDynamic && port !== 0) {
|
||||
retriedDynamic = true;
|
||||
console.warn(
|
||||
`Sandbox: port ${port} in use; falling back to an OS-assigned port. ` +
|
||||
`The MCP Apps tab will work locally, but the sandbox is no longer ` +
|
||||
`on a predictable port — set MCP_SANDBOX_PORT to a free one if you ` +
|
||||
`need to forward it (container / SSH tunnel / reverse proxy).`,
|
||||
);
|
||||
server!.listen(0, host);
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
`Sandbox: port ${port || "dynamic"} in use. MCP Apps tab may not work.`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { readFileSync } from "node:fs";
|
|||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import open from "open";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import { Hono } from "hono";
|
||||
|
|
@ -22,6 +21,7 @@ import type { WebServerConfig } from "./web-server-config.js";
|
|||
import {
|
||||
webServerConfigToInitialPayload,
|
||||
printServerBanner,
|
||||
openBrowser,
|
||||
} from "./web-server-config.js";
|
||||
import type { WebServerHandle } from "./types.js";
|
||||
|
||||
|
|
@ -125,7 +125,9 @@ export async function startHonoServer(
|
|||
sandboxUrl ?? undefined,
|
||||
);
|
||||
if (config.autoOpen) {
|
||||
open(url);
|
||||
// Never a bare `open(url)`: this callback is synchronous, so a
|
||||
// rejection would escape it. See openBrowser.
|
||||
openBrowser(url);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { Plugin } from "vite";
|
||||
import open from "open";
|
||||
// Direct leaf import — the `core/mcp/remote/node/index.ts` barrel re-exports
|
||||
// from `../constants.js`, which would otherwise drag the broader
|
||||
// `core/mcp/remote/index.ts` barrel (including `createRemoteLogger`, with its
|
||||
|
|
@ -22,6 +21,7 @@ import type { WebServerConfig } from "./web-server-config.js";
|
|||
import {
|
||||
webServerConfigToInitialPayload,
|
||||
printServerBanner,
|
||||
openBrowser,
|
||||
} from "./web-server-config.js";
|
||||
|
||||
export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
|
||||
|
|
@ -114,7 +114,9 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
|
|||
);
|
||||
|
||||
if (config.autoOpen) {
|
||||
open(url);
|
||||
// Never a bare `open(url)`: `logBanner` is synchronous, so a
|
||||
// rejection would escape it. See openBrowser.
|
||||
openBrowser(url);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
* Config object for the web server (dev and prod). Passed in-process; no env handoff.
|
||||
*/
|
||||
|
||||
import open from "open";
|
||||
import pino from "pino";
|
||||
import type { Logger } from "pino";
|
||||
import type { MCPConfig, MCPServerConfig } from "../../../core/mcp/types.ts";
|
||||
|
|
@ -203,6 +204,24 @@ export function printServerBanner(
|
|||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the user's browser at `url`, reporting a failure rather than throwing.
|
||||
*
|
||||
* Both callers — the prod Hono server's `serve()` listen callback and the dev
|
||||
* plugin's `logBanner` — are synchronous, so a rejection has nowhere to go and
|
||||
* escapes as an unhandled rejection (#1959). On a headless or otherwise
|
||||
* browser-less host that rejection is the ordinary outcome, not an edge case,
|
||||
* and by the time it happens the server is already listening and
|
||||
* `printServerBanner` has printed a perfectly usable URL. So warn and carry
|
||||
* on. Shared between the two servers deliberately: two copies of a one-line
|
||||
* `.catch` is two chances for one of them to drift back to a bare `open(url)`.
|
||||
*/
|
||||
export function openBrowser(url: string): void {
|
||||
open(url).catch((err: unknown) => {
|
||||
console.warn("Could not open a browser automatically:", err);
|
||||
});
|
||||
}
|
||||
|
||||
export interface BuildWebServerConfigOptions {
|
||||
initialMcpConfig?: MCPServerConfig | null;
|
||||
/** Catalog/session file the backend should serve; see {@link WebServerConfig.mcpConfigPath}. */
|
||||
|
|
|
|||
|
|
@ -363,7 +363,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
|||
InspectorView: (props: {
|
||||
toolCallState?: { status?: string };
|
||||
toolsUi?: {
|
||||
selectedToolName?: string;
|
||||
selectedToolKey?: string;
|
||||
formValues: Record<string, unknown>;
|
||||
search: string;
|
||||
};
|
||||
|
|
@ -384,7 +384,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
|||
onConnectionInfo: () => void;
|
||||
onToggleConnection: (id: string) => void;
|
||||
onToolsUiChange: (next: {
|
||||
selectedToolName?: string;
|
||||
selectedToolKey?: string;
|
||||
formValues: Record<string, unknown>;
|
||||
search: string;
|
||||
}) => void;
|
||||
|
|
@ -433,7 +433,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
|||
{Object.keys(props.progressByTaskId ?? {}).join(",") || "none"}
|
||||
</span>
|
||||
<span data-testid="selected-tool">
|
||||
{props.toolsUi?.selectedToolName ?? "none"}
|
||||
{props.toolsUi?.selectedToolKey ?? "none"}
|
||||
</span>
|
||||
<span data-testid="tool-search">{props.toolsUi?.search || "none"}</span>
|
||||
<span data-testid="selected-prompt">
|
||||
|
|
@ -469,7 +469,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
|||
formValues: {},
|
||||
search: "",
|
||||
...props.toolsUi,
|
||||
selectedToolName: "get_acts",
|
||||
selectedToolKey: "0:get_acts",
|
||||
})
|
||||
}
|
||||
>
|
||||
|
|
@ -481,7 +481,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
|||
formValues: {},
|
||||
search: "",
|
||||
...props.toolsUi,
|
||||
selectedToolName: "other_tool",
|
||||
selectedToolKey: "1:other_tool",
|
||||
})
|
||||
}
|
||||
>
|
||||
|
|
@ -579,6 +579,8 @@ import {
|
|||
import * as McpIndex from "@inspector/core/mcp/index.js";
|
||||
import * as FetchLogModule from "@inspector/core/mcp/state/fetchRequestLogState.js";
|
||||
import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js";
|
||||
import { useManagedTools } from "@inspector/core/react/useManagedTools.js";
|
||||
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";
|
||||
|
|
@ -598,6 +600,7 @@ const DEFAULT_USE_INSPECTOR_CLIENT: ReturnType<typeof useInspectorClient> = {
|
|||
serverInfo: undefined,
|
||||
instructions: undefined,
|
||||
excludedTools: [],
|
||||
malformedListItems: [],
|
||||
appRendererClient: null,
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -868,7 +871,7 @@ describe("App session-scoped state reset on disconnect", () => {
|
|||
expect(screen.getByTestId("tool-status")).toHaveTextContent("ok"),
|
||||
);
|
||||
|
||||
// A search keystroke leaves `selectedToolName` unchanged, so the result
|
||||
// A search keystroke leaves `selectedToolKey` unchanged, so the result
|
||||
// stays put.
|
||||
await user.click(screen.getByText("set-tool-search"));
|
||||
await waitFor(() =>
|
||||
|
|
@ -2421,3 +2424,156 @@ describe("App paginated list pagination toggle (#1721)", () => {
|
|||
expect(lastPush?.paginatedLists).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// A background command runs through `runCommandInBackground`, which owns the
|
||||
// rejection. Before #2049 the 17 `void runWithCommandAuthRecovery(...)` sites
|
||||
// had no handler, so any non-auth failure of the wrapped operation became an
|
||||
// unhandled rejection in the browser — invisible to the type-aware
|
||||
// no-floating-promises rule (`void` is its sanctioned suppression) and fatal to
|
||||
// `smoke:web:browser`, which hard-fails on exactly that signal.
|
||||
describe("App background command rejections (#2049)", () => {
|
||||
const LIST_FAILURE = new Error("list boom");
|
||||
|
||||
/**
|
||||
* Record process-level unhandled rejections for the duration of a test.
|
||||
* Asserting on the captured list — rather than relying on vitest failing the
|
||||
* whole run — is what attributes a regression to this test rather than to
|
||||
* whichever file happened to be running when the rejection surfaced.
|
||||
*/
|
||||
const captureUnhandledRejections = () => {
|
||||
const seen: unknown[] = [];
|
||||
const onUnhandled = (reason: unknown) => {
|
||||
seen.push(reason);
|
||||
};
|
||||
process.on("unhandledRejection", onUnhandled);
|
||||
return {
|
||||
seen,
|
||||
stop: () => {
|
||||
process.off("unhandledRejection", onUnhandled);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Node reports an unhandled rejection at the end of the turn, so drain the
|
||||
// macrotask queue before asserting that none was reported.
|
||||
const settleRejections = async () => {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
clientInstances.length = 0;
|
||||
notificationsMock.show.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore the module defaults the other blocks render against.
|
||||
vi.mocked(useManagedTools).mockReturnValue({
|
||||
tools: [{ name: "get_acts", inputSchema: { type: "object" } }],
|
||||
error: null,
|
||||
listChanged: false,
|
||||
refresh: vi.fn().mockResolvedValue([]),
|
||||
clearListChanged: clearToolsListChangedSpy,
|
||||
});
|
||||
vi.mocked(usePagedTools).mockReturnValue({
|
||||
tools: [],
|
||||
nextCursor: undefined,
|
||||
pageCount: 0,
|
||||
error: null,
|
||||
loadPage: vi.fn(() =>
|
||||
Promise.resolve({ tools: [], nextCursor: undefined }),
|
||||
),
|
||||
clear: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
const connect = async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<App />);
|
||||
await user.click(screen.getByText("connect"));
|
||||
await waitFor(() => expect(clientInstances).toHaveLength(1));
|
||||
return user;
|
||||
};
|
||||
|
||||
it("does not leak an unhandled rejection when an all-pages Refresh fails", async () => {
|
||||
vi.mocked(useManagedTools).mockReturnValue({
|
||||
tools: [],
|
||||
error: LIST_FAILURE,
|
||||
listChanged: false,
|
||||
refresh: vi.fn().mockRejectedValue(LIST_FAILURE),
|
||||
clearListChanged: clearToolsListChangedSpy,
|
||||
});
|
||||
const rejections = captureUnhandledRejections();
|
||||
try {
|
||||
const user = await connect();
|
||||
await user.click(screen.getByText("refresh-tools"));
|
||||
await settleRejections();
|
||||
expect(rejections.seen).toEqual([]);
|
||||
} finally {
|
||||
rejections.stop();
|
||||
}
|
||||
// The panel already renders the failure with a Retry, so no toast is added
|
||||
// on top of it — that is the whole reason this site swallows the rejection.
|
||||
expect(notificationsMock.show).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not leak an unhandled rejection when a paginated Refresh or Load-next-page fails", async () => {
|
||||
vi.mocked(usePagedTools).mockReturnValue({
|
||||
tools: [],
|
||||
// A cursor is what makes Load-next-page actually fetch.
|
||||
nextCursor: "cursor-1",
|
||||
pageCount: 1,
|
||||
error: LIST_FAILURE,
|
||||
loadPage: vi.fn().mockRejectedValue(LIST_FAILURE),
|
||||
clear: vi.fn(),
|
||||
});
|
||||
const rejections = captureUnhandledRejections();
|
||||
try {
|
||||
const user = await connect();
|
||||
// Flipping the mode drives its own page-1 load, which fails too.
|
||||
await user.click(screen.getByText("paginated-on"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"),
|
||||
);
|
||||
await user.click(screen.getByText("refresh-tools"));
|
||||
await user.click(screen.getByText("load-more-tools"));
|
||||
await settleRejections();
|
||||
expect(rejections.seen).toEqual([]);
|
||||
} finally {
|
||||
rejections.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("toasts a set-log-level failure instead of leaking it", async () => {
|
||||
const rejections = captureUnhandledRejections();
|
||||
try {
|
||||
const user = await connect();
|
||||
// Single assertion: the fake client IS an EventTarget, so narrowing it
|
||||
// to that intersection needs no `as unknown as` detour.
|
||||
const client = clientInstances[0] as EventTarget & {
|
||||
setLoggingLevel: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
client.setLoggingLevel.mockRejectedValueOnce(new Error("no logging"));
|
||||
|
||||
await user.click(screen.getByText("set-level"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(notificationsMock.show).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Failed to set log level",
|
||||
message: "no logging",
|
||||
color: "red",
|
||||
}),
|
||||
),
|
||||
);
|
||||
await settleRejections();
|
||||
expect(rejections.seen).toEqual([]);
|
||||
} finally {
|
||||
rejections.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ import { ManagedRequestorTasksState } from "@inspector/core/mcp/state/managedReq
|
|||
import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSubscriptionsState.js";
|
||||
import {
|
||||
cleanRoots,
|
||||
oauthAuthorizationParamsFromSettings,
|
||||
oauthEndpointOverridesFromSettings,
|
||||
serializeMcpConfig,
|
||||
} from "@inspector/core/mcp/serverList.js";
|
||||
import type { ClientConfig } from "@inspector/core/client/types.js";
|
||||
|
|
@ -163,6 +165,7 @@ import {
|
|||
import { buildExportFilename, downloadJsonFile } from "./lib/downloadFile";
|
||||
import { INSPECTOR_SERVERS_TAB } from "./utils/inspectorTabs";
|
||||
import { enrichProtocolEntries } from "./utils/correlateTransportErrors";
|
||||
import { visibleMalformedListItems } from "./utils/malformedListReport";
|
||||
import {
|
||||
parseDeepLink,
|
||||
deepLinkConfigEquals,
|
||||
|
|
@ -990,6 +993,7 @@ function App() {
|
|||
protocolEra,
|
||||
discoverResult,
|
||||
excludedTools,
|
||||
malformedListItems,
|
||||
lastError,
|
||||
} = useInspectorClient(inspectorClient);
|
||||
const {
|
||||
|
|
@ -1027,18 +1031,21 @@ function App() {
|
|||
tools: pagedTools,
|
||||
nextCursor: pagedToolsCursor,
|
||||
pageCount: pagedToolsPageCount,
|
||||
error: pagedToolsLoadError,
|
||||
loadPage: loadToolsPage,
|
||||
} = usePagedTools(inspectorClient, pagedToolsState);
|
||||
const {
|
||||
prompts: pagedPrompts,
|
||||
nextCursor: pagedPromptsCursor,
|
||||
pageCount: pagedPromptsPageCount,
|
||||
error: pagedPromptsLoadError,
|
||||
loadPage: loadPromptsPage,
|
||||
} = usePagedPrompts(inspectorClient, pagedPromptsState);
|
||||
const {
|
||||
resources: pagedResources,
|
||||
nextCursor: pagedResourcesCursor,
|
||||
pageCount: pagedResourcesPageCount,
|
||||
error: pagedResourcesLoadError,
|
||||
loadPage: loadResourcesPage,
|
||||
} = usePagedResources(inspectorClient, pagedResourcesState);
|
||||
// The active server's persisted paginated setting drives the display mode.
|
||||
|
|
@ -1055,15 +1062,25 @@ function App() {
|
|||
setPaginatedListsOverride(null);
|
||||
}, [persistedPaginatedLists, activeServerId]);
|
||||
const paginatedLists = paginatedListsOverride ?? persistedPaginatedLists;
|
||||
// The malformed-entry report is written by the aggregate walk's salvage. In
|
||||
// paginated mode the tools/prompts/resources panels render the paged stores
|
||||
// instead, which never write or clear it — so it would linger above a page it
|
||||
// does not describe. See `visibleMalformedListItems` (#1909 + #1721).
|
||||
const shownMalformedListItems = useMemo(
|
||||
() => visibleMalformedListItems(malformedListItems, paginatedLists),
|
||||
[malformedListItems, paginatedLists],
|
||||
);
|
||||
const connected = connectionStatus === "connected";
|
||||
const toolsPagination = usePaginatedList({
|
||||
connected,
|
||||
paginated: paginatedLists,
|
||||
managedItems: managedTools,
|
||||
managedRefresh: refreshTools,
|
||||
managedError: toolsLoadError,
|
||||
pagedItems: pagedTools,
|
||||
pagedNextCursor: pagedToolsCursor,
|
||||
pagedPageCount: pagedToolsPageCount,
|
||||
pagedError: pagedToolsLoadError,
|
||||
loadPage: loadToolsPage,
|
||||
});
|
||||
const promptsPagination = usePaginatedList({
|
||||
|
|
@ -1071,9 +1088,11 @@ function App() {
|
|||
paginated: paginatedLists,
|
||||
managedItems: managedPrompts,
|
||||
managedRefresh: refreshPrompts,
|
||||
managedError: promptsLoadError,
|
||||
pagedItems: pagedPrompts,
|
||||
pagedNextCursor: pagedPromptsCursor,
|
||||
pagedPageCount: pagedPromptsPageCount,
|
||||
pagedError: pagedPromptsLoadError,
|
||||
loadPage: loadPromptsPage,
|
||||
});
|
||||
const resourcesPagination = usePaginatedList({
|
||||
|
|
@ -1081,9 +1100,11 @@ function App() {
|
|||
paginated: paginatedLists,
|
||||
managedItems: managedResources,
|
||||
managedRefresh: refreshResources,
|
||||
managedError: resourcesLoadError,
|
||||
pagedItems: pagedResources,
|
||||
pagedNextCursor: pagedResourcesCursor,
|
||||
pagedPageCount: pagedResourcesPageCount,
|
||||
pagedError: pagedResourcesLoadError,
|
||||
loadPage: loadResourcesPage,
|
||||
});
|
||||
const tools = toolsPagination.items;
|
||||
|
|
@ -2045,6 +2066,46 @@ function App() {
|
|||
[inspectorClient, activeServerId, handleCommandScopedAuthRecovery],
|
||||
);
|
||||
|
||||
/**
|
||||
* Fire-and-forget form of {@link runWithCommandAuthRecovery}, for a command
|
||||
* whose caller has nothing to await (a click handler, a mode toggle).
|
||||
*
|
||||
* The wrapper rethrows anything that is not an `AuthRecoveryRequiredError`,
|
||||
* so a bare `void runWithCommandAuthRecovery(...)` turns every non-auth
|
||||
* failure — a transport error, a rejected `tools/list` — into an unhandled
|
||||
* rejection in the browser (#2049). Routing every background command through
|
||||
* here is what keeps a new call site from reintroducing that gap by
|
||||
* omission: there is no `void` to forget.
|
||||
*
|
||||
* `errorTitle` picks the reporting, and the choice is per call site:
|
||||
*
|
||||
* - **Omit it** when the operation's own state already renders the failure —
|
||||
* the list loads, whose managed/paged stores record the error and whose
|
||||
* panel shows it with a Retry. A toast there would only duplicate what the
|
||||
* user is already looking at, so the rejection is swallowed deliberately.
|
||||
* - **Pass one** when nothing else records the failure, and the user would
|
||||
* otherwise see the command silently do nothing.
|
||||
*/
|
||||
const runCommandInBackground = useCallback(
|
||||
(
|
||||
operation: () => Promise<unknown>,
|
||||
source: StepUpSource,
|
||||
errorTitle?: string,
|
||||
): void => {
|
||||
void runWithCommandAuthRecovery(operation, source).catch(
|
||||
(err: unknown) => {
|
||||
if (!errorTitle) return;
|
||||
notifications.show({
|
||||
title: errorTitle,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
[runWithCommandAuthRecovery],
|
||||
);
|
||||
|
||||
const resumePendingReauth = useCallback(
|
||||
async (pending: PendingReauth) => {
|
||||
if (reauthResumeInProgressRef.current) {
|
||||
|
|
@ -2301,11 +2362,19 @@ function App() {
|
|||
.map((m) => [m.key, m.value]),
|
||||
)
|
||||
: undefined;
|
||||
const serverAuthorizationParams = savedSettings
|
||||
? oauthAuthorizationParamsFromSettings(savedSettings)
|
||||
: undefined;
|
||||
const serverEndpointOverrides = savedSettings
|
||||
? oauthEndpointOverridesFromSettings(savedSettings)
|
||||
: undefined;
|
||||
const oauthFromServer =
|
||||
savedSettings &&
|
||||
(savedSettings.oauthClientId ||
|
||||
savedSettings.oauthClientSecret ||
|
||||
savedSettings.oauthScopes ||
|
||||
serverAuthorizationParams ||
|
||||
serverEndpointOverrides ||
|
||||
savedSettings.enterpriseManaged)
|
||||
? {
|
||||
...(savedSettings.oauthClientId && {
|
||||
|
|
@ -2317,6 +2386,10 @@ function App() {
|
|||
...(savedSettings.oauthScopes && {
|
||||
scope: savedSettings.oauthScopes,
|
||||
}),
|
||||
...(serverAuthorizationParams && {
|
||||
authorizationParams: serverAuthorizationParams,
|
||||
}),
|
||||
...serverEndpointOverrides,
|
||||
...(savedSettings.enterpriseManaged && {
|
||||
enterpriseManaged: true,
|
||||
}),
|
||||
|
|
@ -3136,17 +3209,17 @@ function App() {
|
|||
// drops the previous tool's result — the result panel renders `toolCallState`
|
||||
// regardless of selection, so without this a stale result would linger under
|
||||
// the newly-selected tool (which has no result of its own yet). Search and
|
||||
// form edits keep `selectedToolName` unchanged, so they leave the result be.
|
||||
// Depends on `selectedToolName` only (not the whole `toolsUi`), so a search
|
||||
// form edits keep `selectedToolKey` unchanged, so they leave the result be.
|
||||
// Depends on `selectedToolKey` only (not the whole `toolsUi`), so a search
|
||||
// keystroke doesn't churn the callback identity.
|
||||
const onToolsUiChange = useCallback(
|
||||
(next: ToolsUiState) => {
|
||||
if (next.selectedToolName !== toolsUi.selectedToolName) {
|
||||
if (next.selectedToolKey !== toolsUi.selectedToolKey) {
|
||||
setToolCallState(undefined);
|
||||
}
|
||||
setToolsUi(next);
|
||||
},
|
||||
[toolsUi.selectedToolName],
|
||||
[toolsUi.selectedToolKey],
|
||||
);
|
||||
|
||||
// --- MCP Apps handlers. Unlike onCallTool (which feeds the Tools panel),
|
||||
|
|
@ -3441,12 +3514,16 @@ function App() {
|
|||
(level: LoggingLevel) => {
|
||||
setCurrentLogLevel(level);
|
||||
if (!inspectorClient) return;
|
||||
void runWithCommandAuthRecovery(
|
||||
// Nothing else records a `logging/setLevel` failure, and the optimistic
|
||||
// level above already moved — so report it, or the selector would sit on
|
||||
// a level the server never accepted with no explanation.
|
||||
runCommandInBackground(
|
||||
() => inspectorClient.setLoggingLevel(level),
|
||||
"ambient",
|
||||
"Failed to set log level",
|
||||
);
|
||||
},
|
||||
[inspectorClient, runWithCommandAuthRecovery],
|
||||
[inspectorClient, runCommandInBackground],
|
||||
);
|
||||
|
||||
// Modern era (#1629): no request is sent — the client stores the level and
|
||||
|
|
@ -3469,51 +3546,39 @@ function App() {
|
|||
// (the managed state lit it on `list_changed`; nothing else clears it in
|
||||
// paginated mode) (#1721).
|
||||
clearToolsListChanged();
|
||||
void runWithCommandAuthRecovery(
|
||||
() => toolsPagination.onRefresh(),
|
||||
"ambient",
|
||||
);
|
||||
runCommandInBackground(() => toolsPagination.onRefresh(), "ambient");
|
||||
} else {
|
||||
void runWithCommandAuthRecovery(() => refreshTools(), "ambient");
|
||||
runCommandInBackground(() => refreshTools(), "ambient");
|
||||
}
|
||||
}, [
|
||||
paginatedLists,
|
||||
toolsPagination,
|
||||
refreshTools,
|
||||
clearToolsListChanged,
|
||||
runWithCommandAuthRecovery,
|
||||
runCommandInBackground,
|
||||
]);
|
||||
const onRefreshPrompts = useCallback(() => {
|
||||
if (paginatedLists) {
|
||||
clearPromptsListChanged();
|
||||
void runWithCommandAuthRecovery(
|
||||
() => promptsPagination.onRefresh(),
|
||||
"ambient",
|
||||
);
|
||||
runCommandInBackground(() => promptsPagination.onRefresh(), "ambient");
|
||||
} else {
|
||||
void runWithCommandAuthRecovery(() => refreshPrompts(), "ambient");
|
||||
runCommandInBackground(() => refreshPrompts(), "ambient");
|
||||
}
|
||||
}, [
|
||||
paginatedLists,
|
||||
promptsPagination,
|
||||
refreshPrompts,
|
||||
clearPromptsListChanged,
|
||||
runWithCommandAuthRecovery,
|
||||
runCommandInBackground,
|
||||
]);
|
||||
const onRefreshResources = useCallback(() => {
|
||||
if (paginatedLists) {
|
||||
clearResourcesListChanged();
|
||||
void runWithCommandAuthRecovery(
|
||||
() => resourcesPagination.onRefresh(),
|
||||
"ambient",
|
||||
);
|
||||
runCommandInBackground(() => resourcesPagination.onRefresh(), "ambient");
|
||||
// Resource templates always use the managed (aggregate) path.
|
||||
void runWithCommandAuthRecovery(
|
||||
() => refreshResourceTemplates(),
|
||||
"ambient",
|
||||
);
|
||||
runCommandInBackground(() => refreshResourceTemplates(), "ambient");
|
||||
} else {
|
||||
void runWithCommandAuthRecovery(async () => {
|
||||
runCommandInBackground(async () => {
|
||||
await refreshResources();
|
||||
await refreshResourceTemplates();
|
||||
}, "ambient");
|
||||
|
|
@ -3524,7 +3589,7 @@ function App() {
|
|||
refreshResources,
|
||||
refreshResourceTemplates,
|
||||
clearResourcesListChanged,
|
||||
runWithCommandAuthRecovery,
|
||||
runCommandInBackground,
|
||||
]);
|
||||
// The per-list sidebar toggle edits the server-wide `paginatedLists` setting:
|
||||
// optimistic override for an instant flip, live push so the managed state's
|
||||
|
|
@ -3548,22 +3613,13 @@ function App() {
|
|||
// Wrap in ambient auth recovery so a mid-session 401 triggers re-auth
|
||||
// rather than surfacing raw, matching the all-pages refresh path.
|
||||
if (value) {
|
||||
void runWithCommandAuthRecovery(
|
||||
() => loadToolsPage(undefined),
|
||||
"ambient",
|
||||
);
|
||||
void runWithCommandAuthRecovery(
|
||||
() => loadPromptsPage(undefined),
|
||||
"ambient",
|
||||
);
|
||||
void runWithCommandAuthRecovery(
|
||||
() => loadResourcesPage(undefined),
|
||||
"ambient",
|
||||
);
|
||||
runCommandInBackground(() => loadToolsPage(undefined), "ambient");
|
||||
runCommandInBackground(() => loadPromptsPage(undefined), "ambient");
|
||||
runCommandInBackground(() => loadResourcesPage(undefined), "ambient");
|
||||
} else {
|
||||
void runWithCommandAuthRecovery(() => refreshTools(), "ambient");
|
||||
void runWithCommandAuthRecovery(() => refreshPrompts(), "ambient");
|
||||
void runWithCommandAuthRecovery(() => refreshResources(), "ambient");
|
||||
runCommandInBackground(() => refreshTools(), "ambient");
|
||||
runCommandInBackground(() => refreshPrompts(), "ambient");
|
||||
runCommandInBackground(() => refreshResources(), "ambient");
|
||||
}
|
||||
}
|
||||
void updateServerSettings(activeServerId, next).catch((err: unknown) => {
|
||||
|
|
@ -3592,34 +3648,24 @@ function App() {
|
|||
refreshTools,
|
||||
refreshPrompts,
|
||||
refreshResources,
|
||||
runWithCommandAuthRecovery,
|
||||
runCommandInBackground,
|
||||
],
|
||||
);
|
||||
// Wrap Load-next-page in ambient auth recovery too, so a paginated
|
||||
// paginated fetch that hits a 401 recovers like the all-pages path (#1721).
|
||||
const onLoadMoreTools = useCallback(
|
||||
() =>
|
||||
void runWithCommandAuthRecovery(
|
||||
() => toolsPagination.onLoadMore(),
|
||||
"ambient",
|
||||
),
|
||||
[toolsPagination, runWithCommandAuthRecovery],
|
||||
() => runCommandInBackground(() => toolsPagination.onLoadMore(), "ambient"),
|
||||
[toolsPagination, runCommandInBackground],
|
||||
);
|
||||
const onLoadMorePrompts = useCallback(
|
||||
() =>
|
||||
void runWithCommandAuthRecovery(
|
||||
() => promptsPagination.onLoadMore(),
|
||||
"ambient",
|
||||
),
|
||||
[promptsPagination, runWithCommandAuthRecovery],
|
||||
runCommandInBackground(() => promptsPagination.onLoadMore(), "ambient"),
|
||||
[promptsPagination, runCommandInBackground],
|
||||
);
|
||||
const onLoadMoreResources = useCallback(
|
||||
() =>
|
||||
void runWithCommandAuthRecovery(
|
||||
() => resourcesPagination.onLoadMore(),
|
||||
"ambient",
|
||||
),
|
||||
[resourcesPagination, runWithCommandAuthRecovery],
|
||||
runCommandInBackground(() => resourcesPagination.onLoadMore(), "ambient"),
|
||||
[resourcesPagination, runCommandInBackground],
|
||||
);
|
||||
const toolsPaginationControls: ListPaginationControlsProps = {
|
||||
paginated: toolsPagination.paginated,
|
||||
|
|
@ -3643,16 +3689,12 @@ function App() {
|
|||
onLoadMore: onLoadMoreResources,
|
||||
};
|
||||
const onRefreshTasks = useCallback(() => {
|
||||
void runWithCommandAuthRecovery(() => refreshTasks(), "ambient")?.catch(
|
||||
(err: unknown) => {
|
||||
notifications.show({
|
||||
title: "Failed to refresh tasks",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
runCommandInBackground(
|
||||
() => refreshTasks(),
|
||||
"ambient",
|
||||
"Failed to refresh tasks",
|
||||
);
|
||||
}, [refreshTasks, runWithCommandAuthRecovery]);
|
||||
}, [refreshTasks, runCommandInBackground]);
|
||||
|
||||
const onClearLogs = useCallback(() => {
|
||||
if (!messageLogState) return;
|
||||
|
|
@ -4376,15 +4418,18 @@ function App() {
|
|||
latencyMs={latencyMs}
|
||||
tools={tools}
|
||||
excludedTools={excludedTools}
|
||||
malformedListItems={shownMalformedListItems}
|
||||
prompts={prompts}
|
||||
resources={resources}
|
||||
resourceTemplates={resourceTemplates}
|
||||
toolsListChanged={toolsListChanged}
|
||||
promptsListChanged={promptsListChanged}
|
||||
resourcesListChanged={resourcesListChanged}
|
||||
toolsLoadError={toolsLoadError}
|
||||
promptsLoadError={promptsLoadError}
|
||||
resourcesLoadError={resourcesLoadError ?? resourceTemplatesLoadError}
|
||||
toolsLoadError={toolsPagination.error}
|
||||
promptsLoadError={promptsPagination.error}
|
||||
resourcesLoadError={
|
||||
resourcesPagination.error ?? resourceTemplatesLoadError
|
||||
}
|
||||
subscriptions={subscriptions}
|
||||
subscriptionStreamState={subscriptionStreamState}
|
||||
logs={logs}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const Transport: Story = {
|
|||
args: { category: "transport" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("transport")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("transport")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -22,6 +22,6 @@ export const Auth: Story = {
|
|||
args: { category: "auth" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("auth")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("auth")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,17 @@ describe("CopyButton", () => {
|
|||
expect(screen.getByText("⎘")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names itself 'Copy' with no label, and qualifies the name when given one", () => {
|
||||
const { unmount } = renderWithMantine(<CopyButton value="hello" />);
|
||||
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
renderWithMantine(<CopyButton value="hello" label="ID Token" />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Copy ID Token" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("triggers a click without throwing when clipboard is unavailable", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<CopyButton value="hello" />);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,15 @@ export interface CopyButtonProps {
|
|||
* rows (e.g. beside a Code block). Icon size is unchanged.
|
||||
*/
|
||||
flush?: boolean;
|
||||
/**
|
||||
* Names what is being copied, for the accessible label only ("Copy ID
|
||||
* Token"). Set it when more than one CopyButton can appear in the same
|
||||
* region, so button-by-button screen-reader navigation can tell them apart;
|
||||
* a lone control needs no qualifier. The visible tooltip stays "Copy" either
|
||||
* way, and the accessible name keeps "Copy"/"Copied" as its first word so it
|
||||
* still contains the visible text (WCAG 2.5.3).
|
||||
*/
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const CopyActionIcon = ActionIcon.withProps({
|
||||
|
|
@ -18,7 +27,8 @@ const CopyActionIcon = ActionIcon.withProps({
|
|||
fz: 24,
|
||||
});
|
||||
|
||||
export function CopyButton({ value, flush = false }: CopyButtonProps) {
|
||||
export function CopyButton({ value, flush = false, label }: CopyButtonProps) {
|
||||
const qualifier = label ? ` ${label}` : "";
|
||||
return (
|
||||
<MantineCopyButton value={value}>
|
||||
{({ copied, copy }) => (
|
||||
|
|
@ -26,7 +36,7 @@ export function CopyButton({ value, flush = false }: CopyButtonProps) {
|
|||
<CopyActionIcon
|
||||
color={copied ? "green" : "var(--inspector-text-primary)"}
|
||||
onClick={copy}
|
||||
aria-label={copied ? "Copied" : "Copy"}
|
||||
aria-label={(copied ? "Copied" : "Copy") + qualifier}
|
||||
{...(flush && { p: 0, h: "auto", w: "auto" })}
|
||||
>
|
||||
{copied ? "\u2713" : "\u2398"}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export const FullSize: Story = {
|
|||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("Row 1")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("Row 1")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -38,6 +38,6 @@ export const Embedded: Story = {
|
|||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("Row 1")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("Row 1")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ export const Collapsed: Story = {
|
|||
args: { expanded: false },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByRole("button", { name: "Expand" })).toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.getByRole("button", { name: "Expand" }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -23,7 +25,7 @@ export const Expanded: Story = {
|
|||
args: { expanded: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
await expect(
|
||||
canvas.getByRole("button", { name: "Collapse" }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { MalformedItemsWarning } from "./MalformedItemsWarning";
|
||||
|
||||
const meta: Meta<typeof MalformedItemsWarning> = {
|
||||
title: "Elements/MalformedItemsWarning",
|
||||
component: MalformedItemsWarning,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof MalformedItemsWarning>;
|
||||
|
||||
/** The failure this was built for: a PHP server whose empty `annotations`
|
||||
* object reaches the wire as `[]` (#1909). */
|
||||
export const OneDroppedTemplate: Story = {
|
||||
args: {
|
||||
method: "resources/templates/list",
|
||||
what: "resource templates",
|
||||
items: [
|
||||
{
|
||||
method: "resources/templates/list",
|
||||
index: 1,
|
||||
label: "array_annotations",
|
||||
reason: "annotations: Invalid input: expected object, received array",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const SeveralDropped: Story = {
|
||||
args: {
|
||||
method: "tools/list",
|
||||
what: "tools",
|
||||
items: [
|
||||
{
|
||||
method: "tools/list",
|
||||
index: 0,
|
||||
label: "get_weather",
|
||||
reason: "inputSchema: Invalid input: expected object, received string",
|
||||
},
|
||||
{
|
||||
method: "tools/list",
|
||||
index: 4,
|
||||
label: "add",
|
||||
reason: "name: Invalid input: expected string, received number",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** An entry too broken to carry a name is reported by its position. */
|
||||
export const UnlabeledEntry: Story = {
|
||||
args: {
|
||||
method: "prompts/list",
|
||||
what: "prompts",
|
||||
items: [
|
||||
{
|
||||
method: "prompts/list",
|
||||
index: 2,
|
||||
reason: "Invalid input: expected object, received null",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** Entries belonging to another list method are ignored — the same set is
|
||||
* passed to every panel, and each selects its own. */
|
||||
export const OtherMethodOnly: Story = {
|
||||
args: {
|
||||
method: "resources/templates/list",
|
||||
what: "resource templates",
|
||||
items: [
|
||||
{
|
||||
method: "tools/list",
|
||||
index: 0,
|
||||
label: "get_weather",
|
||||
reason: "inputSchema: Invalid input: expected object, received string",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** The resting state: a conforming server, nothing rendered. */
|
||||
export const Conforming: Story = {
|
||||
args: {
|
||||
method: "resources/templates/list",
|
||||
what: "resource templates",
|
||||
items: [],
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import type { MalformedListItem } from "@inspector/core/mcp";
|
||||
import { renderWithMantine } from "../../../test/renderWithMantine";
|
||||
import { MalformedItemsWarning } from "./MalformedItemsWarning";
|
||||
|
||||
const TEMPLATE_ENTRY: MalformedListItem = {
|
||||
method: "resources/templates/list",
|
||||
index: 1,
|
||||
label: "array_annotations",
|
||||
reason: "annotations: Invalid input: expected object, received array",
|
||||
};
|
||||
|
||||
const TOOL_ENTRY: MalformedListItem = {
|
||||
method: "tools/list",
|
||||
index: 0,
|
||||
label: "get_weather",
|
||||
reason: "inputSchema: Invalid input: expected object, received string",
|
||||
};
|
||||
|
||||
describe("MalformedItemsWarning", () => {
|
||||
it("names the dropped entry and why it was dropped", () => {
|
||||
renderWithMantine(
|
||||
<MalformedItemsWarning
|
||||
items={[TEMPLATE_ENTRY]}
|
||||
method="resources/templates/list"
|
||||
what="resource templates"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("1 malformed entry dropped")).toBeInTheDocument();
|
||||
expect(screen.getByText("array_annotations")).toBeInTheDocument();
|
||||
expect(screen.getByText(TEMPLATE_ENTRY.reason)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing when the server was conforming", () => {
|
||||
renderWithMantine(
|
||||
<MalformedItemsWarning
|
||||
items={[]}
|
||||
method="resources/templates/list"
|
||||
what="resource templates"
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores entries belonging to another list method", () => {
|
||||
// Every panel receives the whole set and selects its own — a tools failure
|
||||
// must not surface above the templates list.
|
||||
renderWithMantine(
|
||||
<MalformedItemsWarning
|
||||
items={[TOOL_ENTRY]}
|
||||
method="resources/templates/list"
|
||||
what="resource templates"
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("get_weather")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pluralizes the count and lists every entry", () => {
|
||||
renderWithMantine(
|
||||
<MalformedItemsWarning
|
||||
items={[
|
||||
TOOL_ENTRY,
|
||||
{ ...TOOL_ENTRY, index: 4, label: "add", reason: "name: bad" },
|
||||
TEMPLATE_ENTRY,
|
||||
]}
|
||||
method="tools/list"
|
||||
what="tools"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("2 malformed entries dropped")).toBeInTheDocument();
|
||||
expect(screen.getByText("get_weather")).toBeInTheDocument();
|
||||
expect(screen.getByText("add")).toBeInTheDocument();
|
||||
// The template entry belongs to the other panel.
|
||||
expect(screen.queryByText("array_annotations")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the position when the entry had no usable name", () => {
|
||||
renderWithMantine(
|
||||
<MalformedItemsWarning
|
||||
items={[
|
||||
{
|
||||
method: "prompts/list",
|
||||
index: 2,
|
||||
reason: "Invalid input: expected object, received null",
|
||||
},
|
||||
]}
|
||||
method="prompts/list"
|
||||
what="prompts"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("entry 2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import { Alert, Code, List, ScrollArea, Text } from "@mantine/core";
|
||||
import type { MalformedListItem } from "@inspector/core/mcp";
|
||||
|
||||
export interface MalformedItemsWarningProps {
|
||||
/**
|
||||
* Every malformed entry the client is currently reporting, across all list
|
||||
* methods — pass the hook's set straight through; the component selects its
|
||||
* own by {@link method}.
|
||||
*/
|
||||
items: MalformedListItem[];
|
||||
/** The list method this panel renders, e.g. `"resources/templates/list"`. */
|
||||
method: string;
|
||||
/** What the entries are, for the alert title — e.g. "resource templates". */
|
||||
what: string;
|
||||
}
|
||||
|
||||
// Yellow, not red: unlike ListLoadError this is not a failure — the list DID
|
||||
// load and is rendered below. It reports what the server sent that the
|
||||
// Inspector could not accept.
|
||||
const WarningAlert = Alert.withProps({
|
||||
color: "yellow",
|
||||
variant: "light",
|
||||
});
|
||||
|
||||
// The entry list stays compact; the reasons are the payload, so they get the
|
||||
// monospace treatment while the labels stay in body type.
|
||||
const EntryList = List.withProps({
|
||||
size: "xs",
|
||||
spacing: 4,
|
||||
withPadding: true,
|
||||
});
|
||||
|
||||
const EntryReason = Code.withProps({
|
||||
variant: "wrapping",
|
||||
});
|
||||
|
||||
// A server can be wrong about many entries at once; cap the height so a badly
|
||||
// broken list can't push the list itself off the sidebar.
|
||||
const EntryScroll = ScrollArea.withProps({
|
||||
mah: 180,
|
||||
type: "auto",
|
||||
});
|
||||
|
||||
const SummaryText = Text.withProps({
|
||||
size: "xs",
|
||||
mb: 4,
|
||||
});
|
||||
|
||||
// The entry's identity, emphasized against its monospaced reason.
|
||||
const EntryLabel = Text.withProps({
|
||||
component: "span",
|
||||
size: "xs",
|
||||
fw: 600,
|
||||
});
|
||||
|
||||
/** `label` when the entry had one, else its position in the response. */
|
||||
function describeEntry(item: MalformedListItem): string {
|
||||
return item.label ?? `entry ${item.index}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Some entries were dropped" — the visible half of the #1909 salvage.
|
||||
*
|
||||
* The client keeps the valid entries of a list whose result failed schema
|
||||
* validation, rather than losing the whole list to one bad entry. Dropping them
|
||||
* silently would trade one lie for another, so what was dropped, and why, is
|
||||
* reported here beside the list that rendered without them.
|
||||
*/
|
||||
export function MalformedItemsWarning({
|
||||
items,
|
||||
method,
|
||||
what,
|
||||
}: MalformedItemsWarningProps) {
|
||||
const mine = items.filter((item) => item.method === method);
|
||||
if (mine.length === 0) return null;
|
||||
|
||||
return (
|
||||
<WarningAlert
|
||||
title={`${mine.length} malformed ${
|
||||
mine.length === 1 ? "entry" : "entries"
|
||||
} dropped`}
|
||||
>
|
||||
<SummaryText>
|
||||
The server returned {what} that don't match the MCP schema. The
|
||||
rest of the list is shown below.
|
||||
</SummaryText>
|
||||
<EntryScroll>
|
||||
<EntryList>
|
||||
{mine.map((item) => (
|
||||
<List.Item key={`${item.method}:${item.index}`}>
|
||||
<EntryLabel>{describeEntry(item)}</EntryLabel>{" "}
|
||||
<EntryReason>{item.reason}</EntryReason>
|
||||
</List.Item>
|
||||
))}
|
||||
</EntryList>
|
||||
</EntryScroll>
|
||||
</WarningAlert>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ export const HeaderMismatch: Story = {
|
|||
args: { code: -32020, name: "HeaderMismatch" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("-32020 HeaderMismatch")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("-32020 HeaderMismatch")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ export const MissingCapability: Story = {
|
|||
args: { code: -32021, name: "MissingRequiredClientCapability" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
await expect(
|
||||
canvas.getByText("-32021 MissingRequiredClientCapability"),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
|
|
@ -32,7 +32,7 @@ export const UnsupportedVersion: Story = {
|
|||
args: { code: -32022, name: "UnsupportedProtocolVersion" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
await expect(
|
||||
canvas.getByText("-32022 UnsupportedProtocolVersion"),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
|
|
@ -47,6 +47,6 @@ export const MethodNotFound: Story = {
|
|||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("-32601 MethodNotFound")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("-32601 MethodNotFound")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const Outgoing: Story = {
|
|||
args: { direction: "outgoing" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("client → server")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("client → server")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -22,6 +22,6 @@ export const Incoming: Story = {
|
|||
args: { direction: "incoming" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("server → client")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("server → client")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const ToolsList: Story = {
|
|||
args: { method: "tools/list" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("tools/list")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("tools/list")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -22,6 +22,6 @@ export const TasksList: Story = {
|
|||
args: { method: "tasks/list" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("tasks/list")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("tasks/list")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue