Merge branch 'v2/main' into v2/chore/2102-remove-claude-workflow
This commit is contained in:
commit
f6cff76aed
85 changed files with 3743 additions and 688 deletions
|
|
@ -110,15 +110,15 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en
|
|||
| `--prompt-name <name>` | Prompt name (for `prompts/get`). |
|
||||
| `--prompt-args <key=value>` | Prompt arguments; repeat for multiple. |
|
||||
| `--log-level <level>` | Logging level for `logging/setLevel` (e.g. `debug`, `info`). |
|
||||
| `--metadata <key=value>` | General metadata (key=value); applied to all methods. |
|
||||
| `--tool-metadata <key=value>` | Tool-specific metadata for `tools/call`. |
|
||||
| `--metadata <key=value>` | General `_meta` entries (key=value); applied to all methods. The value is JSON-parsed when it parses, so `trace={"id":"abc"}` sends a real object and `n=3` a number; anything that is not valid JSON is sent as the literal string. |
|
||||
| `--tool-metadata <key=value>` | Tool-specific `_meta` entries for `tools/call`. Same JSON-parsed value handling as `--metadata`. |
|
||||
| `--connect-timeout <ms>` | Connection timeout in ms. Defaults to `15000` for ad-hoc `--server-url`/target runs (so a black-holed host fails fast) and to the file-level timeout for `--catalog`/`--config` runs. `0` disables the timeout. |
|
||||
| `--app-info` | Probe a tool's MCP App UI metadata without invoking it. With `--method tools/call --tool-name <name>`: prints one JSON line (`hasApp`, `resourceUri`, `csp`, `permissions`, `domain`, …) and exits `0` if the tool has an app or `2` (`no_app`) if not. With `--method tools/list`: emits NDJSON — one app-info line per tool over a single connection. |
|
||||
| `--format <text\|json>` | Output format. `text` (default) pretty-prints the result. `json` emits a single JSON object on stdout (`{ "result": … }`, plus `{ "appInfo": … }` as a sibling key for App tools) with no banners, so the whole output pipes cleanly into `jq`. |
|
||||
| `--relogin` | Delete stored OAuth for this server URL from the shared store before connect; interactive login still only runs if the server requires auth. Requires an HTTP/SSE URL (rejected for stdio). Conflicts with `--stored-auth-only` / `--use-stored-auth` / `--wait-for-auth` / catalog short-circuits. |
|
||||
| `--stored-auth-only` | **CI / non-interactive safe:** never start interactive OAuth / step-up (and never auto-open a browser); use the shared store if present, otherwise fail immediately with `auth_required`. Prefer this over a bare pipe/CI run that would otherwise attempt interactive login. |
|
||||
|
||||
`servers/show` redacts secret-bearing fields (`env` values, sensitive headers / `settings.metadata` keys, `requestInit` / `eventSourceInit` headers, `oauthClientSecret`). It does **not** scrub credentials embedded in a server `url` (userinfo or query tokens) or in stdio `args` — treat `detail` / raw URL fields as potentially sensitive before pasting into issues.
|
||||
`servers/show` redacts secret-bearing fields (`env` values, sensitive headers, sensitive `settings.metadata` keys whose whole value is replaced whether or not it is structured, `requestInit` / `eventSourceInit` headers, `oauthClientSecret`). It does **not** scrub credentials embedded in a server `url` (userinfo or query tokens) or in stdio `args` — treat `detail` / raw URL fields as potentially sensitive before pasting into issues.
|
||||
|
||||
#### App probing (`--app-info`) and machine-readable output (`--format json`)
|
||||
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export function makeFakeServerSettings(
|
|||
): InspectorServerSettings {
|
||||
return {
|
||||
headers: [],
|
||||
metadata: [],
|
||||
metadata: {},
|
||||
env: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
|
|
|
|||
|
|
@ -373,16 +373,17 @@ describe("Metadata Tests", () => {
|
|||
|
||||
expectCliSuccess(result);
|
||||
|
||||
// Validate metadata values are sent as strings
|
||||
// A JSON-parseable value goes out as that JSON type, not as its
|
||||
// string spelling (#1910) — `_meta` takes any JSON.
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
const toolsListRequest = recordedRequests.find(
|
||||
(r) => r.method === "tools/list",
|
||||
);
|
||||
expect(toolsListRequest).toBeDefined();
|
||||
expect(toolsListRequest?.metadata).toEqual({
|
||||
integer_value: "42",
|
||||
decimal_value: "3.14159",
|
||||
negative_value: "-10",
|
||||
integer_value: 42,
|
||||
decimal_value: 3.14159,
|
||||
negative_value: -10,
|
||||
});
|
||||
} finally {
|
||||
await server.stop();
|
||||
|
|
@ -428,7 +429,7 @@ describe("Metadata Tests", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("JSON.stringifies object/array metadata (not String → [object Object])", async () => {
|
||||
it("sends object/array/boolean metadata as real JSON, not stringified (#1910)", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
|
|
@ -456,9 +457,9 @@ describe("Metadata Tests", () => {
|
|||
(r) => r.method === "tools/list",
|
||||
);
|
||||
expect(toolsListRequest?.metadata).toEqual({
|
||||
nested: '{"key":"value"}',
|
||||
list: "[1,2,3]",
|
||||
flag: "true",
|
||||
nested: { key: "value" },
|
||||
list: [1, 2, 3],
|
||||
flag: true,
|
||||
});
|
||||
} finally {
|
||||
await server.stop();
|
||||
|
|
@ -819,7 +820,7 @@ describe("Metadata Tests", () => {
|
|||
);
|
||||
expect(toolsListRequest).toBeDefined();
|
||||
expect(toolsListRequest?.metadata).toEqual({
|
||||
integration_test: "true",
|
||||
integration_test: true,
|
||||
test_phase: "all_methods",
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -866,8 +867,8 @@ describe("Metadata Tests", () => {
|
|||
);
|
||||
expect(toolCallRequest).toBeDefined();
|
||||
expect(toolCallRequest?.metadata).toEqual({
|
||||
session_id: "12345",
|
||||
user_id: "67890",
|
||||
session_id: 12345,
|
||||
user_id: 67890,
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
request_id: "req-abc-123",
|
||||
tool_session: "session-xyz-789",
|
||||
|
|
@ -879,6 +880,65 @@ describe("Metadata Tests", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an overflowing literal", "n=1e400"],
|
||||
["a negative overflow", "n=-1e400"],
|
||||
["one nested in an object", 'o={"a":1e400}'],
|
||||
])("rejects %s rather than sending null", async (_label, pair) => {
|
||||
// `JSON.parse` accepts these and yields ±Infinity, which
|
||||
// `JSON.stringify` writes as `null` — so accepting the flag would
|
||||
// transmit a value the user did not ask for.
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--metadata",
|
||||
pair,
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(`${result.stderr}${result.stdout}`).toMatch(/finite/i);
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("names the key but never the value when rejecting", async () => {
|
||||
// The pair can carry a credential, and this message reaches stderr and
|
||||
// CI logs.
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--metadata",
|
||||
'credentials={"accessToken":"sk-live-nope","n":1e400}',
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
const output = `${result.stderr}${result.stdout}`;
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(output).toContain("credentials");
|
||||
expect(output).not.toContain("sk-live");
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle metadata parsing validation", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
|
|
@ -919,8 +979,8 @@ describe("Metadata Tests", () => {
|
|||
expect(toolCallRequest).toBeDefined();
|
||||
expect(toolCallRequest?.metadata).toEqual({
|
||||
valid_key: "valid_value",
|
||||
numeric_key: "123",
|
||||
boolean_key: "true",
|
||||
numeric_key: 123,
|
||||
boolean_key: true,
|
||||
json_key: '\'{"test":"value"}\'', // Single quotes are preserved
|
||||
special_key: "!@#$%^&*()",
|
||||
unicode_key: "🚀🎉✨",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isOneShotMethod,
|
||||
metaValueToString,
|
||||
ONE_SHOT_METHODS,
|
||||
SESSION_RPC_METHODS,
|
||||
} from "../src/handlers/method-types.js";
|
||||
|
|
@ -26,11 +25,3 @@ describe("ONE_SHOT_METHODS", () => {
|
|||
expect(ONE_SHOT_METHODS).not.toContain("logging/tail");
|
||||
});
|
||||
});
|
||||
|
||||
describe("metaValueToString", () => {
|
||||
it("passes strings through and JSON-encodes structured values", () => {
|
||||
expect(metaValueToString("plain")).toBe("plain");
|
||||
expect(metaValueToString({ a: 1 })).toBe('{"a":1}');
|
||||
expect(metaValueToString([1, 2])).toBe("[1,2]");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
describe("withConnectTimeout", () => {
|
||||
const baseSettings: InspectorServerSettings = {
|
||||
headers: [{ key: "X-Test", value: "1" }],
|
||||
metadata: [],
|
||||
metadata: {},
|
||||
env: [],
|
||||
connectionTimeout: 42,
|
||||
requestTimeout: 0,
|
||||
|
|
|
|||
|
|
@ -219,10 +219,23 @@ describe("showServerEntry / servers/show", () => {
|
|||
{ key: "Authorization", value: "Bearer x" },
|
||||
{ key: "X-Custom", value: "ok" },
|
||||
],
|
||||
metadata: [
|
||||
{ key: "Authorization", value: "Bearer meta" },
|
||||
{ key: "X-Custom", value: "ok" },
|
||||
],
|
||||
metadata: {
|
||||
Authorization: "Bearer meta",
|
||||
"X-Custom": "ok",
|
||||
// A structured value under a sensitive key: the redaction must replace
|
||||
// the whole value, not walk into it (#1910).
|
||||
"X-Api-Key": { primary: "sk-live-1", fallback: "sk-live-2" },
|
||||
nested: { keep: true },
|
||||
// A secret buried under a non-sensitive key. A top-level-only check
|
||||
// would print `accessToken` in full.
|
||||
trace: {
|
||||
id: "t-1",
|
||||
accessToken: "sk-live-3",
|
||||
deeper: { refresh_token: "sk-live-4", ok: 1 },
|
||||
},
|
||||
// Objects inside an array are reached too.
|
||||
attempts: [{ password: "hunter2" }, { attempt: 2 }],
|
||||
},
|
||||
env: [
|
||||
{ key: "TOKEN", value: "secret" },
|
||||
{ key: "", value: "still-secret" },
|
||||
|
|
@ -242,10 +255,20 @@ describe("showServerEntry / servers/show", () => {
|
|||
{ key: "Authorization", value: "[redacted]" },
|
||||
{ key: "X-Custom", value: "ok" },
|
||||
]);
|
||||
expect(sanitized.metadata).toEqual([
|
||||
{ key: "Authorization", value: "[redacted]" },
|
||||
{ key: "X-Custom", value: "ok" },
|
||||
]);
|
||||
expect(sanitized.metadata).toEqual({
|
||||
Authorization: "[redacted]",
|
||||
"X-Custom": "ok",
|
||||
"X-Api-Key": "[redacted]",
|
||||
// A structured value under a non-sensitive key survives intact...
|
||||
nested: { keep: true },
|
||||
// ...but a sensitive key nested inside one does not.
|
||||
trace: {
|
||||
id: "t-1",
|
||||
accessToken: "[redacted]",
|
||||
deeper: { refresh_token: "[redacted]", ok: 1 },
|
||||
},
|
||||
attempts: [{ password: "[redacted]" }, { attempt: 2 }],
|
||||
});
|
||||
expect(sanitized.env).toEqual([
|
||||
{ key: "TOKEN", value: "[redacted]" },
|
||||
{ key: "", value: "[redacted]" },
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import {
|
|||
parseHeaderPair,
|
||||
} from "@inspector/core/mcp/node/index.js";
|
||||
import type { JsonValue } from "@inspector/core/mcp/index.js";
|
||||
import type { StrictJsonValue } from "@inspector/core/json/jsonUtils.js";
|
||||
import { isSerializableJson } from "@inspector/core/json/jsonUtils.js";
|
||||
import {
|
||||
canonicalUrlHost,
|
||||
isAllInterfacesHost,
|
||||
|
|
@ -33,7 +35,6 @@ import { consumeMethodOutcome } from "./handlers/consume-outcome.js";
|
|||
import { runMethod } from "./handlers/run-method.js";
|
||||
import {
|
||||
isOneShotMethod,
|
||||
metaValueToString,
|
||||
ONE_SHOT_METHODS,
|
||||
type MethodArgs,
|
||||
} from "./handlers/method-types.js";
|
||||
|
|
@ -474,8 +475,8 @@ function buildHandoff(
|
|||
|
||||
function parseKeyValuePair(
|
||||
value: string,
|
||||
previous: Record<string, JsonValue> = {},
|
||||
): Record<string, JsonValue> {
|
||||
previous: Record<string, StrictJsonValue> = {},
|
||||
): Record<string, StrictJsonValue> {
|
||||
const parts = value.split("=");
|
||||
const key = parts[0];
|
||||
const val = parts.slice(1).join("=");
|
||||
|
|
@ -486,13 +487,31 @@ function parseKeyValuePair(
|
|||
);
|
||||
}
|
||||
|
||||
let parsedValue: JsonValue;
|
||||
// `StrictJsonValue`: `JSON.parse` cannot produce `undefined`, and these values
|
||||
// become `_meta`, which must reach the wire exactly as written (#1910).
|
||||
let parsedValue: StrictJsonValue;
|
||||
try {
|
||||
parsedValue = JSON.parse(val) as JsonValue;
|
||||
parsedValue = JSON.parse(val) as StrictJsonValue;
|
||||
} catch {
|
||||
// Not JSON at all — a bare word or an unquoted string. Sent as a string,
|
||||
// which is what the user plainly meant.
|
||||
parsedValue = val;
|
||||
}
|
||||
|
||||
// Valid JSON syntax is not the same as sendable JSON: `1e400` parses to
|
||||
// `Infinity`, which `JSON.stringify` writes as `null`. Rejecting is better
|
||||
// than accepting the flag and silently transmitting a different value —
|
||||
// and better than falling back to the literal string, which would also not
|
||||
// be what was asked for.
|
||||
if (!isSerializableJson(parsedValue)) {
|
||||
// Names the key, never the value: the pair can carry a credential
|
||||
// (`credentials={"accessToken":"…","n":1e400}`) and this message lands in
|
||||
// stderr and CI logs.
|
||||
throw new Error(
|
||||
`Invalid value for "${key}": numbers must be finite (a literal like 1e400 overflows to Infinity and cannot be sent).`,
|
||||
);
|
||||
}
|
||||
|
||||
return { ...previous, [key as string]: parsedValue };
|
||||
}
|
||||
|
||||
|
|
@ -736,8 +755,8 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
|
|||
promptName?: string;
|
||||
promptArgs?: Record<string, JsonValue>;
|
||||
logLevel?: LoggingLevel;
|
||||
metadata?: Record<string, JsonValue>;
|
||||
toolMetadata?: Record<string, JsonValue>;
|
||||
metadata?: Record<string, StrictJsonValue>;
|
||||
toolMetadata?: Record<string, StrictJsonValue>;
|
||||
cwd?: string;
|
||||
transport?: "sse" | "http" | "stdio";
|
||||
serverUrl?: string;
|
||||
|
|
@ -999,22 +1018,12 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
|
|||
promptName: options.promptName,
|
||||
promptArgs: options.promptArgs,
|
||||
logLevel: options.logLevel,
|
||||
metadata: options.metadata
|
||||
? Object.fromEntries(
|
||||
Object.entries(options.metadata).map(([key, value]) => [
|
||||
key,
|
||||
metaValueToString(value),
|
||||
]),
|
||||
)
|
||||
: undefined,
|
||||
toolMeta: options.toolMetadata
|
||||
? Object.fromEntries(
|
||||
Object.entries(options.toolMetadata).map(([key, value]) => [
|
||||
key,
|
||||
metaValueToString(value),
|
||||
]),
|
||||
)
|
||||
: undefined,
|
||||
// `--metadata`/`--tool-metadata` values are parsed as JSON, and `_meta`
|
||||
// takes any JSON — so they go through unflattened (#1910). They used to be
|
||||
// squeezed through `metaValueToString`, which sent `{"a":1}` as the
|
||||
// *string* `'{"a":1}'`.
|
||||
metadata: options.metadata,
|
||||
toolMeta: options.toolMetadata,
|
||||
appInfo: options.appInfo === true,
|
||||
format: options.format,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { InspectorClient } from "@inspector/core/mcp/index.js";
|
|||
import { extractAppInfo } from "@inspector/core/mcp/apps.js";
|
||||
import type { AppInfo } from "@inspector/core/mcp/apps.js";
|
||||
import type { CliAppInfo } from "./method-types.js";
|
||||
import type { RequestMetadata } from "@inspector/core/mcp/types.js";
|
||||
|
||||
/**
|
||||
* Build the CLI's app-info for a tool. Never throws — failures fold into
|
||||
|
|
@ -10,7 +11,7 @@ import type { CliAppInfo } from "./method-types.js";
|
|||
export async function collectAppInfo(
|
||||
client: Pick<InspectorClient, "readResource">,
|
||||
tool: Parameters<typeof extractAppInfo>[0],
|
||||
metadata: Record<string, string> | undefined,
|
||||
metadata: RequestMetadata | undefined,
|
||||
): Promise<CliAppInfo> {
|
||||
let base: AppInfo;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export function withConnectTimeout(
|
|||
if (settings) return { ...settings, connectionTimeout };
|
||||
return {
|
||||
headers: [],
|
||||
metadata: [],
|
||||
metadata: {},
|
||||
env: [],
|
||||
connectionTimeout,
|
||||
requestTimeout: 0,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { JsonValue } from "@inspector/core/mcp/index.js";
|
||||
import type { RequestMetadata } from "@inspector/core/mcp/types.js";
|
||||
import type { AppInfo } from "@inspector/core/mcp/apps.js";
|
||||
import type { LoggingLevel } from "@modelcontextprotocol/client";
|
||||
import type { OutputFormat } from "./format-output.js";
|
||||
|
|
@ -20,8 +21,8 @@ export type MethodArgs = {
|
|||
logLevel?: LoggingLevel;
|
||||
toolName?: string;
|
||||
toolArg?: Record<string, JsonValue>;
|
||||
toolMeta?: Record<string, string>;
|
||||
metadata?: Record<string, string>;
|
||||
toolMeta?: RequestMetadata;
|
||||
metadata?: RequestMetadata;
|
||||
appInfo?: boolean;
|
||||
format?: OutputFormat;
|
||||
/** Task id for tasks/get, tasks/cancel, tasks/result. */
|
||||
|
|
@ -110,13 +111,3 @@ export type OneShotMethod = (typeof ONE_SHOT_METHODS)[number];
|
|||
export function isOneShotMethod(method: string): method is OneShotMethod {
|
||||
return (ONE_SHOT_METHODS as readonly string[]).includes(method);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a metadata / tool-metadata value for MethodArgs (string map).
|
||||
* Strings pass through; objects/arrays use JSON.stringify so structure is kept
|
||||
* (avoids String(obj) → "[object Object]").
|
||||
*/
|
||||
export function metaValueToString(value: JsonValue): string {
|
||||
if (typeof value === "string") return value;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,10 +157,10 @@ export function sanitizeServerSettings(
|
|||
key: h.key,
|
||||
value: isSensitiveHeader(h.key) ? REDACTED : h.value,
|
||||
})),
|
||||
metadata: (settings.metadata ?? []).map((m) => ({
|
||||
key: m.key,
|
||||
value: isSensitiveHeader(m.key) ? REDACTED : m.value,
|
||||
})),
|
||||
// Metadata values may be any JSON (#1910), so the redaction has to descend:
|
||||
// a top-level key that is not itself sensitive can hold a nested one that
|
||||
// is. See `redactMetadataValue`.
|
||||
metadata: redactMetadataValue(settings.metadata ?? {}),
|
||||
env: (settings.env ?? []).map((e) => ({
|
||||
key: e.key,
|
||||
value: REDACTED,
|
||||
|
|
@ -212,6 +212,39 @@ function sanitizeInitRecord(
|
|||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact secret-bearing entries anywhere inside a `_meta` payload.
|
||||
*
|
||||
* The pre-#1910 version only had to check the top level, because a value was
|
||||
* always a string — a secret could not hide below one. Now that a value may be
|
||||
* an object or an array, `{ trace: { accessToken: "…" } }` would sail through a
|
||||
* top-level-only check: `trace` is not sensitive, so the whole subtree,
|
||||
* `accessToken` included, would print. `servers/show` output is meant to be
|
||||
* safe to paste into an issue, so the walk has to reach every key.
|
||||
*
|
||||
* A key matching {@link isSensitiveHeader} replaces its **entire** value rather
|
||||
* than being descended into, so a sensitive key holding an object cannot leak
|
||||
* through its members either. Arrays are mapped elementwise: their indices are
|
||||
* not names anyone can mark sensitive, but objects inside them can be.
|
||||
*
|
||||
* Recursion is bounded by the payload, which came from `JSON.parse` and so is a
|
||||
* finite tree with no cycles.
|
||||
*/
|
||||
function redactMetadataValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(redactMetadataValue);
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, member]) => [
|
||||
key,
|
||||
isSensitiveHeader(key) ? REDACTED : redactMetadataValue(member),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isSensitiveHeader(key: string): boolean {
|
||||
const k = key.toLowerCase();
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -102,7 +102,13 @@ const h = vi.hoisted(() => {
|
|||
// handler for the event (the common single-server case).
|
||||
type EventEntry = { client: unknown; fn: (event: unknown) => void };
|
||||
const clientEvents = new Map<string, Set<EventEntry>>();
|
||||
const clientInstances: Array<{ cfg?: { type?: string; url?: string } }> = [];
|
||||
const clientInstances: Array<{
|
||||
cfg?: { type?: string; url?: string };
|
||||
// The options the mount effect built for this server. Captured so a test
|
||||
// can assert what was actually handed to the client — `defaultMetadata`
|
||||
// in particular, which is otherwise invisible to a render-only assertion.
|
||||
opts?: { defaultMetadata?: unknown };
|
||||
}> = [];
|
||||
const fireClientEvent = (event: string, detail?: unknown) => {
|
||||
clientEvents.get(event)?.forEach((e) => e.fn({ detail }));
|
||||
};
|
||||
|
|
@ -127,8 +133,13 @@ const h = vi.hoisted(() => {
|
|||
}
|
||||
class FakeClient {
|
||||
cfg: { type?: string; url?: string } | undefined;
|
||||
constructor(config?: { type?: string; url?: string }) {
|
||||
opts: { defaultMetadata?: unknown } | undefined;
|
||||
constructor(
|
||||
config?: { type?: string; url?: string },
|
||||
opts?: { defaultMetadata?: unknown },
|
||||
) {
|
||||
this.cfg = config;
|
||||
this.opts = opts;
|
||||
clientInstances.push(this);
|
||||
}
|
||||
// Derive the transport type from the server config the client was built
|
||||
|
|
@ -340,7 +351,7 @@ function oneEmaHttp(): Record<string, TuiServer> {
|
|||
config: { type: "streamable-http", url: "http://localhost:8080/mcp" },
|
||||
settings: {
|
||||
requestTimeout: 0,
|
||||
metadata: [],
|
||||
metadata: {},
|
||||
headers: [],
|
||||
env: [],
|
||||
roots: [],
|
||||
|
|
@ -393,10 +404,10 @@ function httpWithSettings(): Record<string, TuiServer> {
|
|||
config: { type: "streamable-http", url: "http://x" },
|
||||
settings: {
|
||||
requestTimeout: 5000,
|
||||
metadata: [
|
||||
{ key: "team", value: "alpha" },
|
||||
{ key: " ", value: "ignored" },
|
||||
],
|
||||
// Object-shaped with a nested value (#1910) — the shape the TUI now
|
||||
// forwards verbatim. As a pair array this would have reached the wire
|
||||
// as numeric `_meta` keys.
|
||||
metadata: { team: "alpha", trace: { id: "abc", hops: [1, 2] } },
|
||||
oauthClientId: "cid",
|
||||
oauthClientSecret: "secret",
|
||||
oauthScopes: "read write",
|
||||
|
|
@ -1114,6 +1125,13 @@ describe("App (input handling, focus, effects)", () => {
|
|||
it("builds a client with saved settings (metadata, oauth, timeout)", async () => {
|
||||
const r = await mount(httpWithSettings());
|
||||
await expectFrame(r, "MCP Servers");
|
||||
// Not just "it mounted": the saved metadata must reach the client as the
|
||||
// same object, nesting intact (#1910).
|
||||
const web = h.clientInstances.find((c) => c.cfg?.url === "http://x");
|
||||
expect(web?.opts?.defaultMetadata).toEqual({
|
||||
team: "alpha",
|
||||
trace: { id: "abc", hops: [1, 2] },
|
||||
});
|
||||
});
|
||||
|
||||
it("passes top-level oauth client credentials into an http client", async () => {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export function makeFakeServerSettings(
|
|||
): InspectorServerSettings {
|
||||
return {
|
||||
headers: [],
|
||||
metadata: [],
|
||||
metadata: {},
|
||||
env: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
|
|
|
|||
|
|
@ -291,13 +291,9 @@ function App({
|
|||
transport: createTransportNode,
|
||||
logger: getTuiLogger(),
|
||||
};
|
||||
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 clientAuthOptions = buildRunnerClientAuthOptions(
|
||||
clientConfig,
|
||||
savedSettings,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})),
|
||||
|
|
@ -355,6 +368,29 @@ vi.mock("@inspector/core/react/useSettingsDraft.js", () => ({
|
|||
})),
|
||||
}));
|
||||
|
||||
// --- App bridge factory spy (#2055) -----------------------------------------
|
||||
// Passes through to the real factory but records the deps App hands it, so a
|
||||
// test can drive `getListedResourceMeta` — the wiring that carries a
|
||||
// `resources/list` entry's `_meta.ui` into the sandbox CSP. Without this the
|
||||
// bridge-factory unit tests would still pass while App stopped supplying it.
|
||||
const appBridgeFactoryDeps: AppBridgeFactoryDeps[] = [];
|
||||
vi.mock(
|
||||
"./components/elements/AppRenderer/createAppBridgeFactory",
|
||||
async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("./components/elements/AppRenderer/createAppBridgeFactory")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
createAppBridgeFactory: (deps: AppBridgeFactoryDeps) => {
|
||||
appBridgeFactoryDeps.push(deps);
|
||||
return actual.createAppBridgeFactory(deps);
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// --- InspectorView double ---------------------------------------------------
|
||||
// Surfaces each piece of session-scoped state under test and exposes buttons
|
||||
// that invoke the App's connect / call-tool / get-prompt / read-resource /
|
||||
|
|
@ -412,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;
|
||||
|
|
@ -537,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>
|
||||
|
|
@ -584,12 +631,19 @@ 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,
|
||||
ServerEntry,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
import type { AppBridgeFactoryDeps } from "./components/elements/AppRenderer/createAppBridgeFactory";
|
||||
import { useManagedResources } from "@inspector/core/react/useManagedResources.js";
|
||||
import type { UseManagedResourcesResult } from "@inspector/core/react/useManagedResources.js";
|
||||
import type { Resource } from "@modelcontextprotocol/client";
|
||||
|
||||
// Default useInspectorClient return — capabilities empty (no task tool calls).
|
||||
// Individual tests override via vi.mocked(...).mockReturnValue(...).
|
||||
|
|
@ -1638,7 +1692,7 @@ const settingsWithRoots = (
|
|||
): InspectorServerSettings => ({
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
metadata: {},
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 60000,
|
||||
|
|
@ -2963,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
|
||||
|
|
@ -3117,3 +3401,66 @@ describe("App background command rejections (#2049)", () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** A complete `useManagedResources` return, so the mock stays type-checked against the hook's contract. */
|
||||
function managedResourcesResult(
|
||||
resources: Resource[],
|
||||
): UseManagedResourcesResult {
|
||||
return {
|
||||
error: null,
|
||||
resources,
|
||||
listChanged: false,
|
||||
refresh: vi.fn().mockResolvedValue(resources),
|
||||
clearListChanged: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("App MCP App listed-resource metadata wiring (#2055)", () => {
|
||||
beforeEach(() => {
|
||||
appBridgeFactoryDeps.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// The hook mock is module-level and shared, so put the empty-list default
|
||||
// back rather than leaving a populated list for whatever runs next.
|
||||
vi.mocked(useManagedResources).mockReturnValue(managedResourcesResult([]));
|
||||
});
|
||||
|
||||
it("hands the bridge factory a getListedResourceMeta reading the resources/list entries", async () => {
|
||||
// ext-apps treats a listing entry's `_meta.ui` as the static default for a
|
||||
// UI resource, so the App has to surface the listing to the bridge — the
|
||||
// factory's own tests inject the dep and cannot see this wiring break.
|
||||
const listedMeta = {
|
||||
ui: { csp: { connectDomains: ["https://api.example.com"] } },
|
||||
};
|
||||
vi.mocked(useManagedResources).mockReturnValue(
|
||||
managedResourcesResult([
|
||||
{ uri: "ui://weather/app.html", name: "app", _meta: listedMeta },
|
||||
]),
|
||||
);
|
||||
|
||||
renderWithMantine(<App />);
|
||||
|
||||
// App builds two factories, differing only in `advertiseElicitation`. Wait
|
||||
// for BOTH by that flag rather than for a count: waiting on "at least one"
|
||||
// would still pass with the wiring stripped from either call site, since
|
||||
// the other's deps sit in the same array.
|
||||
const appsFactory = () =>
|
||||
appBridgeFactoryDeps.find((d) => !d.advertiseElicitation);
|
||||
const elicitationFactory = () =>
|
||||
appBridgeFactoryDeps.find((d) => d.advertiseElicitation === true);
|
||||
await waitFor(() => {
|
||||
expect(appsFactory()).toBeDefined();
|
||||
expect(elicitationFactory()).toBeDefined();
|
||||
});
|
||||
|
||||
for (const deps of [appsFactory(), elicitationFactory()]) {
|
||||
expect(deps?.getListedResourceMeta?.("ui://weather/app.html")).toEqual(
|
||||
listedMeta,
|
||||
);
|
||||
expect(
|
||||
deps?.getListedResourceMeta?.("ui://other/app.html"),
|
||||
).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue