feat: flag unportable tool schemas in all three clients

Closes #1005

A tool schema can be perfectly legal JSON Schema and still be rejected by
the client the server is meant to run against — Go's `jsonschema` emits a
bare `true` for `interface{}`, which the SDK accepts and Claude Code
refuses with an opaque "Invalid input". The Inspector passed it through
silently.

Adds `core/json/schemaLint.ts`, a portability lint over a tool's
inputSchema/outputSchema, and surfaces one shared verdict everywhere:
the CLI's `--strict` report (exit 6 on an error-severity finding), the
TUI's tool list marker + detail pane, and the web Tools sidebar flag +
detail section.

Deliberately not a JSON Schema validator: a census of 617 public servers
found zero that fail the SDK's own parse, so a conformance check would
report nothing on real servers. Each rule is instead a construct that is
legal JSON Schema and known to be refused or mishandled by a shipping
client — a bare boolean in a schema position, an array-form `type`, a
remote `$ref`, a schema with no validation keyword, a non-object root.

`test-servers/configs/unportable-schemas-http.json` exercises every rule,
via a new `rawToolSchemas` override — the Zod-built presets cannot emit
any of these shapes, which is the same reason a real server only hits
this when its schemas come from another generator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-25 01:18:53 -04:00
commit 2f9e0901c1
26 changed files with 1979 additions and 13 deletions

View file

@ -157,7 +157,20 @@ v2/main/
│ │ # Shared by BOTH form builders — web SchemaForm
│ │ # and TUI schemaToForm — since each dispatches on
│ │ # a single `type` string and would otherwise miss
│ │ # a nullable field entirely — #1928/#2015)
│ │ # a nullable field entirely — #1928/#2015;
│ │ # schemaLint.ts: tool-schema PORTABILITY lint —
│ │ # constructs that are legal JSON Schema and are
│ │ # refused or mishandled by real MCP clients (a bare
│ │ # `true` in `properties`, an array-form `type`, a
│ │ # remote `$ref`, a schema carrying no validation
│ │ # keyword, a non-object root). Deliberately NOT a
│ │ # validator: a census of 617 public servers found 0
│ │ # that fail the SDK's own parse, so a conformance
│ │ # check reports nothing on real servers. ONE verdict
│ │ # for all three clients — the CLI's `--strict` report
│ │ # (exit 6), the TUI's tool detail pane, the web Tools
│ │ # tab — so they cannot disagree about what is
│ │ # portable; the report TEXT is shared too — #1005)
│ ├── logging/ # Silent pino logger singleton
│ ├── mcp/ # InspectorClient runtime + state stores
│ │ # (uriTemplate.ts: RFC 6570 parse/classify/expand.

View file

@ -34,8 +34,9 @@ inspector/
│ │ # plus per-server secret storage — the keychain/file/memory SecretStore
│ │ # implementations, the selection policy, and the descriptor the banner and UI report
│ ├── client/ # Install-level client config (`client.json`): browser-safe parse/validate + Node load/save, remote backend, secrets
│ ├── json/ # JSON + parameter/argument conversion utilities, and the nullable-union
│ │ # schema collapse shared by the web and TUI form builders
│ ├── json/ # JSON + parameter/argument conversion utilities, the nullable-union
│ │ # schema collapse shared by the web and TUI form builders, and the
│ │ # tool-schema portability lint all three clients report from
│ ├── logging/ # Silent pino logger singleton
│ ├── mcp/ # InspectorClient runtime, state stores, transports, config import,
│ │ # and the RFC 6570 URI-template helpers the web form and TUI expand through
@ -152,6 +153,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa
| `structured-output-http.json` | Tools tab: a result's `structuredContent` section | [#1908](https://github.com/modelcontextprotocol/inspector/issues/1908) |
| `duplicate-tool-names-http.json` | A `tools/list` that repeats a tool name | [#1957](https://github.com/modelcontextprotocol/inspector/issues/1957) |
| `nullable-fields-http.json` | Tools tab: nullable (`anyOf` + `null`) arguments | [#1928](https://github.com/modelcontextprotocol/inspector/issues/1928) |
| `unportable-schemas-http.json` **(legacy era)** | Tool schemas a real client rejects, flagged in all three clients | [#1005](https://github.com/modelcontextprotocol/inspector/issues/1005) |
| `rfc6570-templates-http.json` | Resources tab: RFC 6570 resource-template expansion | [#1919](https://github.com/modelcontextprotocol/inspector/issues/1919) |
| `advertised-extensions-http.json` | Tool registration gated on advertised extensions | [#1739](https://github.com/modelcontextprotocol/inspector/issues/1739) |
| `oauth-custom-resource-metadata-http.json` **(legacy era)** | OAuth discovery driven by the challenge's `resource_metadata` | [#2071](https://github.com/modelcontextprotocol/inspector/issues/2071) |
@ -286,6 +288,47 @@ Open the Tools tab and select `record_shipment`: `direction` must render as a **
The **TUI** had the same gap and is worth checking against the same server (`--tui`, then test `record_shipment`): `direction` is a select, `quantity` an integer field, `express` a boolean. Both clients now share one collapse step — `normalizeNullableUnion` in [`core/json/nullableUnion.ts`](./core/json/nullableUnion.ts) — precisely so they cannot drift on which schemas they can render.
#### Unportable tool schemas
`unportable-schemas-http.json` serves three tools, two of whose advertised
schemas carry constructs that are legal JSON Schema and are refused or
mishandled by real MCP clients: `echo` declares `outputSchema.properties.data`
as a bare `true` (what Go's `jsonschema` package emits for `interface{}` —
the case reported in [#1005](https://github.com/modelcontextprotocol/inspector/issues/1005)),
an array-form `"type": ["null","boolean"]`, and an `opts` property that
constrains nothing; `add` points a property at a remote `$ref`. `get_temp` is
left clean, so a flagged tool sits next to an unflagged one. Plain
streamable-HTTP — connect with the **default (legacy)** protocol era.
The Inspector is where a server author looks first, so a construct that will
fail downstream is named here rather than passed through silently. All three
clients report the same verdict from
[`core/json/schemaLint.ts`](./core/json/schemaLint.ts), each with the room it
has:
```bash
mcp-inspector --cli http://127.0.0.1:6603/mcp --method tools/list --strict # exits 6
```
- **CLI** — `--strict` prints the full report (path, issue, suggested fix) on
stderr and exits `6` on an error-severity finding; without it, one summary
line. See [Schema portability](./clients/cli/README.md#schema-portability---strict).
- **TUI** — the tools list marks `echo` with a red `!` and `add` with a yellow
`?`; the detail pane lists each finding under **Schema Portability**.
- **Web** — the Tools sidebar row carries the same flag as a hover-labelled
icon, and selecting the tool shows a **Schema portability** section above the
argument form.
This is deliberately **not** a JSON Schema validator. A census of 617 public
servers (14,804 tool schemas) reported on that issue found **0** that fail the
SDK's own `ListToolsResultSchema.safeParse`, so a conformance check would
report nothing on essentially every real server. What bites is the narrower
subset each consumer accepts, and each rule here is a construct known to be
refused or degraded by a shipping client. The schemas are supplied through the
test server's `rawToolSchemas` override, because the Zod-built presets cannot
express any of them — which is the same reason a real server hits this only
when its schemas come from another generator.
#### RFC 6570 resource templates
`rfc6570-templates-http.json` serves two resource templates straight out of [#1919](https://github.com/modelcontextprotocol/inspector/issues/1919) — `events_by_topic` (`foobar://events/{topic}`) and `events_by_query` (`foobar://events{?topic}`) — each echoing the URI it was matched against, plus a plain `foobar://events` resource (see below). Plain streamable-HTTP; connect with the **default (legacy)** protocol era.

View file

@ -120,6 +120,7 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en
| `--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. |
| `--strict` | With `--method tools/list`: report tool-schema portability problems in full (path, issue, suggested fix) on stderr, and exit `6` if any is error-severity. Without it, a one-line count is printed instead. See [Schema portability](#schema-portability---strict). |
| `--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. |
@ -152,6 +153,50 @@ mcp-inspector --cli <server> --method tools/call --tool-name my_app_tool --forma
A `tools/call` that returns `isError:true` still prints its payload but exits `5` (`tool_is_error`) so `&&` chains don't proceed on a failed call.
#### Schema portability (`--strict`)
A tool schema can be perfectly legal JSON Schema and still be refused by the
client the server is meant to run against. `--strict` (with `--method
tools/list`) names those constructs — path, what is wrong, and a concrete fix —
on **stderr**, and exits `6` if any is error-severity:
```bash
mcp-inspector --cli <server> --method tools/list --strict
```
```text
Error: tool "info"
Path: outputSchema.properties.data
Issue: Bare `true` used where a schema object is expected.
Suggestion: Spell the "anything" schema out — `{"type": "object", "additionalProperties": true}` …
1 error, 3 warnings across 2 tools.
```
Severity decides the exit code, not the report: **errors** are constructs a
shipping MCP client refuses outright (a bare `true` in a `properties` map, a
non-object schema root), **warnings** are ones handled unevenly (an array-form
`type`, a remote `$ref`, a schema carrying no validation keyword at all). Only
errors fail the run — a `--strict` that failed on warnings would be unusable as
a CI gate against servers that are in fact fine.
The report goes to stderr, so the result on stdout stays parseable. Under
`--format json` the findings are folded into the same envelope instead
(`{"result":…,"schemaFindings":[…]}`), so a caller reads one document rather
than correlating two.
**Without `--strict` nothing changes except one line.** A `tools/list` whose
schemas have findings prints a single stderr summary — `Schema portability: 1
error, 3 warnings across 2 tools. Re-run with --strict for details.` — and
still exits `0`. A clean list prints nothing at all.
This is deliberately not a JSON Schema validator. A census of 617 public
servers found **zero** that fail the SDK's own parser, so a conformance check
would report nothing on essentially every real server; what bites is the
narrower subset each consumer accepts, which is what these rules encode. The
same verdict drives the TUI's tool detail pane and the web Tools tab — all
three read `core/json/schemaLint`.
### CLI-specific (OAuth for HTTP servers)
The CLI runs the same loopback callback server as the TUI (`http://127.0.0.1:6276/oauth/callback` by default).
@ -250,6 +295,7 @@ prose from stderr:
| `3` | Server requires authentication (401/403, `WWW-Authenticate`, OAuth). |
| `4` | Server unreachable (DNS, connection refused, timeout, `fetch failed`). |
| `5` | Tool error (`tools/call` returned `isError:true`, or the tool was not found). |
| `6` | `--strict` found an error-severity tool-schema portability problem. |
On any non-zero exit the CLI also writes a single JSON line to **stderr** — the
`ErrorEnvelope`:

View file

@ -0,0 +1,251 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { emitResult, runCli } from "../src/cli.js";
import { CliExitCodeError, EXIT_CODES } from "../src/error-handler.js";
import {
lintListResult,
toolsFromResult,
writeSchemaLintReport,
} from "../src/handlers/schema-lint-report.js";
/** A `tools/list` result with one bare-`true` property — the issue's case. */
const DIRTY_LIST = {
tools: [
{
name: "info",
inputSchema: { type: "object", properties: {} },
outputSchema: { type: "object", properties: { data: true } },
},
],
};
/** Same shape, but every schema portable. */
const CLEAN_LIST = {
tools: [{ name: "ok", inputSchema: { type: "object", properties: {} } }],
};
describe("toolsFromResult", () => {
it("returns the tools array", () => {
expect(toolsFromResult(DIRTY_LIST).map((t) => t.name)).toEqual(["info"]);
});
it.each([
["a missing tools key", {}],
["a non-array tools value", { tools: "nope" }],
])("returns nothing for %s", (_label, result) => {
expect(toolsFromResult(result)).toEqual([]);
});
it("skips entries that are not tool-shaped", () => {
const result = {
tools: [null, 3, {}, { name: 7 }, { name: "real", inputSchema: {} }],
};
expect(toolsFromResult(result).map((t) => t.name)).toEqual(["real"]);
});
});
describe("lintListResult", () => {
it("reports the offending tool", () => {
const results = lintListResult(DIRTY_LIST);
expect(results.map((r) => r.toolName)).toEqual(["info"]);
});
it("reports nothing for a clean list", () => {
expect(lintListResult(CLEAN_LIST)).toEqual([]);
});
});
describe("writeSchemaLintReport", () => {
let stderr: string;
let originalWrite: typeof process.stderr.write;
beforeEach(() => {
stderr = "";
originalWrite = process.stderr.write;
process.stderr.write = ((chunk: unknown): boolean => {
stderr += typeof chunk === "string" ? chunk : String(chunk);
return true;
}) as typeof process.stderr.write;
});
afterEach(() => {
process.stderr.write = originalWrite;
});
it("writes nothing at all when there are no findings", () => {
writeSchemaLintReport([], true);
expect(stderr).toBe("");
});
it("writes a one-line hint without --strict", () => {
writeSchemaLintReport(lintListResult(DIRTY_LIST), false);
expect(stderr.trimEnd().split("\n")).toHaveLength(1);
expect(stderr).toContain("Re-run with --strict");
});
it("writes the full report under --strict", () => {
writeSchemaLintReport(lintListResult(DIRTY_LIST), true);
expect(stderr).toContain('Error: tool "info"');
expect(stderr).toContain("Path: outputSchema.properties.data");
expect(stderr).toContain("Suggestion: ");
expect(stderr).not.toContain("Re-run with --strict");
});
});
describe("emitResult — schema lint wiring (#1005)", () => {
let stdout: string;
let stderr: string;
let originalOut: typeof process.stdout.write;
let originalErr: typeof process.stderr.write;
beforeEach(() => {
stdout = "";
stderr = "";
originalOut = process.stdout.write;
originalErr = process.stderr.write;
process.stdout.write = ((chunk: unknown, ...rest: unknown[]): boolean => {
stdout += typeof chunk === "string" ? chunk : String(chunk);
(
rest.find((r) => typeof r === "function") as (() => void) | undefined
)?.();
return true;
}) as typeof process.stdout.write;
process.stderr.write = ((chunk: unknown): boolean => {
stderr += typeof chunk === "string" ? chunk : String(chunk);
return true;
}) as typeof process.stderr.write;
});
afterEach(() => {
process.stdout.write = originalOut;
process.stderr.write = originalErr;
});
it("exits 6 under --strict when a finding is error-severity", async () => {
const promise = emitResult(DIRTY_LIST, undefined, {
method: "tools/list",
strict: true,
format: "text",
});
await expect(promise).rejects.toBeInstanceOf(CliExitCodeError);
await promise.catch((e: CliExitCodeError) => {
expect(e.exitCode).toBe(EXIT_CODES.SCHEMA_INVALID);
expect(e.envelope?.code).toBe("schema_invalid");
});
// The result still goes to stdout; only the report is on stderr.
expect(JSON.parse(stdout)).toEqual(DIRTY_LIST);
expect(stderr).toContain("Path: outputSchema.properties.data");
});
it("succeeds under --strict when only warnings were found", async () => {
const warnOnly = {
tools: [
{
name: "w",
inputSchema: {
type: "object",
properties: { a: { type: ["null", "boolean"] } },
},
},
],
};
await expect(
emitResult(warnOnly, undefined, {
method: "tools/list",
strict: true,
format: "text",
}),
).resolves.toBeUndefined();
expect(stderr).toContain('Warning: tool "w"');
});
it("prints only the hint, and does not fail, without --strict", async () => {
await expect(
emitResult(DIRTY_LIST, undefined, {
method: "tools/list",
format: "text",
}),
).resolves.toBeUndefined();
expect(stderr).toContain("Re-run with --strict");
expect(stderr).not.toContain("Suggestion:");
});
it("folds findings into the --format json envelope under --strict", async () => {
await emitResult(DIRTY_LIST, undefined, {
method: "tools/list",
strict: true,
format: "json",
}).catch(() => {
// exit 6 is expected here; the envelope is what this test asserts.
});
const envelope = JSON.parse(stdout) as {
result: unknown;
schemaFindings?: { toolName: string }[];
};
expect(envelope.result).toEqual(DIRTY_LIST);
expect(envelope.schemaFindings?.[0]?.toolName).toBe("info");
});
it("leaves the json envelope alone when nothing was found", async () => {
await emitResult(CLEAN_LIST, undefined, {
method: "tools/list",
strict: true,
format: "json",
});
expect(JSON.parse(stdout)).toEqual({ result: CLEAN_LIST });
expect(stderr).toBe("");
});
it("does not lint a method other than tools/list", async () => {
// The same payload under `tools/call` must produce no report at all —
// `--strict` is a tools/list flag and the CLI rejects it elsewhere.
await emitResult(DIRTY_LIST, undefined, {
method: "tools/call",
strict: true,
format: "text",
});
expect(stderr).toBe("");
});
it("does not lint an --app-info run", async () => {
await emitResult(
DIRTY_LIST,
{ hasApp: true, toolName: "info" },
{ method: "tools/list", strict: true, appInfo: true, format: "text" },
);
expect(stderr).toBe("");
});
it("reports isError before the schema verdict", async () => {
// Both would throw; the tool-level failure is the more specific one and
// must win, so a caller branching on exit 5 is not shadowed by exit 6.
const promise = emitResult({ ...DIRTY_LIST, isError: true }, undefined, {
method: "tools/list",
strict: true,
toolName: "info",
format: "text",
});
await promise.catch((e: CliExitCodeError) => {
expect(e.exitCode).toBe(EXIT_CODES.TOOL_ERROR);
});
await expect(promise).rejects.toBeInstanceOf(CliExitCodeError);
});
});
describe("--strict argument validation", () => {
it("is rejected with a method other than tools/list", async () => {
await expect(
runCli([
"node",
"cli",
"--cli",
"--method",
"tools/call",
"--tool-name",
"x",
"--strict",
"--server-url",
"http://127.0.0.1:1/mcp",
]),
).rejects.toThrow("--strict requires --method tools/list.");
});
});

View file

@ -684,6 +684,10 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
"--app-info",
"Probe the tool's MCP App UI metadata (resourceUri, csp, permissions, domain) and emit it as one JSON line; exit 2 when the tool has no app. Use with --method tools/call --tool-name <name> (the tool itself is not invoked) or --method tools/list (one NDJSON line per tool).",
)
.option(
"--strict",
"Report tool-schema portability problems in full (path, issue, suggested fix) on stderr, and exit 6 if any is error-severity. Use with --method tools/list. Without it, a one-line count is printed instead.",
)
.option(
"--connect-timeout <ms>",
`Connection timeout in ms (default ${DEFAULT_CONNECT_TIMEOUT_MS} for ad-hoc --server-url / target invocations; 0 = no timeout).`,
@ -784,6 +788,7 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
serverUrl?: string;
header?: Record<string, string>;
appInfo?: boolean;
strict?: boolean;
connectTimeout?: number;
format?: OutputFormat;
toolArgsJson?: string;
@ -1004,6 +1009,10 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
);
}
if (options.strict && options.method !== "tools/list") {
throw new Error("--strict requires --method tools/list.");
}
// --tool-args-json passes arguments verbatim with no key=value coercion (so
// `"012"` stays a string and nested objects work without shell escaping).
let toolArg = options.toolArg;
@ -1047,6 +1056,7 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
metadata: options.metadata,
toolMeta: options.toolMetadata,
appInfo: options.appInfo === true,
strict: options.strict === true,
format: options.format,
};

View file

@ -8,6 +8,7 @@
* - 3: server requires authentication (401 / WWW-Authenticate / OAuth)
* - 4: server unreachable (DNS, connect refused, timeout, fetch failure)
* - 5: tool error (`tools/call` returned `isError:true`, or tool not found)
* - 6: `--strict` found an error-severity tool-schema portability finding
*/
export const EXIT_CODES = {
OK: 0,
@ -16,6 +17,7 @@ export const EXIT_CODES = {
AUTH_REQUIRED: 3,
UNREACHABLE: 4,
TOOL_ERROR: 5,
SCHEMA_INVALID: 6,
} as const;
/** Machine-readable error envelope written as one JSON line on stderr. */

View file

@ -1,10 +1,13 @@
import { awaitableLog } from "../utils/awaitable-log.js";
import { CliExitCodeError, EXIT_CODES } from "../error-handler.js";
import { lintListResult, writeSchemaLintReport } from "./schema-lint-report.js";
import { countFindings } from "@inspector/core/json/schemaLint.js";
import type { CliAppInfo, McpResponse, MethodArgs } from "./method-types.js";
/**
* Write the method result (and any app-info) to stdout, honouring `--format`
* and `--app-info`, then map `isError`/no-app outcomes onto the exit-code map.
* and `--app-info`, then map `isError`/no-app/schema outcomes onto the
* exit-code map.
*/
export async function emitResult(
result: McpResponse,
@ -28,14 +31,23 @@ export async function emitResult(
return;
}
// Lint before writing, because `--format json --strict` folds the findings
// into the same envelope as the result rather than emitting a second
// document a caller would have to correlate.
const lint =
args.method === "tools/list" ? lintListResult(result) : undefined;
if (json) {
const envelope: Record<string, unknown> = { result };
if (appInfo?.hasApp) envelope.appInfo = appInfo;
if (args.strict && lint && lint.length > 0) envelope.schemaFindings = lint;
await awaitableLog(JSON.stringify(envelope) + "\n");
} else {
await awaitableLog(JSON.stringify(result, null, 2) + "\n");
}
if (lint) writeSchemaLintReport(lint, args.strict === true);
if ((result as { isError?: unknown }).isError === true) {
throw new CliExitCodeError(
EXIT_CODES.TOOL_ERROR,
@ -43,4 +55,18 @@ export async function emitResult(
{ code: "tool_is_error" },
);
}
// Only `--strict` fails the run. Warnings never do: they mark constructs
// that are handled unevenly rather than refused, so failing on them would
// make `--strict` unusable as a CI gate on servers that are in fact fine.
if (args.strict && lint) {
const { errors } = countFindings(lint);
if (errors > 0) {
throw new CliExitCodeError(
EXIT_CODES.SCHEMA_INVALID,
`${errors} tool schema error${errors === 1 ? "" : "s"} found (--strict).`,
{ code: "schema_invalid" },
);
}
}
}

View file

@ -24,6 +24,11 @@ export type MethodArgs = {
toolMeta?: RequestMetadata;
metadata?: RequestMetadata;
appInfo?: boolean;
/**
* `--strict`: report tool-schema portability findings in full and exit
* non-zero when any is error-severity (#1005). `tools/list` only.
*/
strict?: boolean;
format?: OutputFormat;
/** Task id for tasks/get, tasks/cancel, tasks/result. */
taskId?: string;

View file

@ -0,0 +1,62 @@
import type { Tool } from "@modelcontextprotocol/client";
import {
formatSchemaLintReport,
lintTools,
summarizeFindings,
type ToolSchemaFindings,
} from "@inspector/core/json/schemaLint.js";
import type { McpResponse } from "./method-types.js";
/**
* Read the `tools` array out of a `tools/list` result. The result is typed as
* an opaque `McpResponse` at this layer, and a server can return anything, so
* each entry is narrowed to "an object with a string `name`" before it is
* treated as a {@link Tool}. Everything else is skipped rather than crashing
* the report — an unlintable entry is not the CLI's problem to surface here.
*/
export function toolsFromResult(result: McpResponse): Tool[] {
const tools = (result as { tools?: unknown }).tools;
if (!Array.isArray(tools)) return [];
return tools.filter(
(t): t is Tool =>
typeof t === "object" &&
t !== null &&
typeof (t as { name?: unknown }).name === "string",
);
}
/**
* Lint the tools in a `tools/list` result (#1005).
*
* Always run, `--strict` or not — it is a pure in-memory walk over a list the
* CLI already holds, and the non-strict hint below needs the count. What
* `--strict` changes is what happens with the result, in
* {@link writeSchemaLintReport}.
*/
export function lintListResult(result: McpResponse): ToolSchemaFindings[] {
return lintTools(toolsFromResult(result));
}
/**
* Write the schema-lint outcome to **stderr**, so it never contaminates the
* result on stdout that a `--format json` consumer is parsing.
*
* Under `--strict` this is the full report from the issue — path, issue,
* suggestion, one block per finding. Without it, a single line naming the
* count and how to see the detail: a server author who has not asked for the
* lint should still learn it found something, but a multi-page report nobody
* requested would be worse than silence.
*/
export function writeSchemaLintReport(
results: readonly ToolSchemaFindings[],
strict: boolean,
): void {
if (results.length === 0) return;
if (!strict) {
process.stderr.write(
`Schema portability: ${summarizeFindings(results)}. Re-run with --strict for details.\n`,
);
return;
}
process.stderr.write(`${formatSchemaLintReport(results)}\n`);
}

View file

@ -75,7 +75,7 @@ The TUI provides terminal-native tabs and panes for interacting with your MCP se
- **Resources**: Browse and read resources exposed by the server.
- **Prompts**: List and test prompts.
- **Tools**: View available tools and execute them with form-like inputs.
- **Tools**: View available tools and execute them with form-like inputs. A tool whose advertised schema carries a portability problem is flagged in the list — red `!` for a construct a shipping MCP client refuses, yellow `?` for one handled unevenly — and the detail pane lists each finding under **Schema Portability** with the path, the problem, and a concrete fix. The verdict comes from [`core/json/schemaLint.ts`](../../core/json/schemaLint.ts), shared with the web Tools tab and the CLI's `--strict` report, so the three cannot disagree ([#1005](https://github.com/modelcontextprotocol/inspector/issues/1005)).
- **Protocol**: View JSON-RPC request/response/notification history (matches the web Protocol monitor).
- **Network**: View HTTP fetch traffic for SSE / Streamable HTTP servers (matches the web Network monitor).
- **Console**: View stdio stderr from the connected server process (matches the web Console monitor).

View file

@ -8,7 +8,7 @@ import type { Tool } from "@modelcontextprotocol/client";
// renders children directly and stubs scrollBy/scrollTo/getViewportHeight.
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
import { ToolsTab } from "../src/components/ToolsTab.js";
import { ToolsTab, schemaMarker } from "../src/components/ToolsTab.js";
// Ink processes stdin keypresses asynchronously — await this after stdin.write
// and after rerender() before asserting.
@ -205,4 +205,84 @@ describe("ToolsTab", () => {
await tick();
expect(onViewDetails).toHaveBeenCalledWith(tools[0]);
});
describe("schema portability (#1005)", () => {
it("marks nothing when every tool's schema is portable", () => {
const { lastFrame } = render(
<ToolsTab tools={tools} isConnected={true} width={120} height={30} />,
);
const frame = lastFrame() ?? "";
expect(frame).not.toContain("Schema Portability");
});
it("flags an offending tool in the list and details it in the pane", () => {
const dirty = makeTool({
name: "info",
description: undefined,
outputSchema: { type: "object", properties: { data: true } },
} as Partial<Tool>);
const { lastFrame } = render(
<ToolsTab tools={[dirty]} isConnected={true} width={140} height={30} />,
);
const frame = lastFrame() ?? "";
// List row carries the error glyph…
expect(frame).toContain("info !");
// …and the detail pane names the path, issue and fix.
expect(frame).toContain("Schema Portability (1)");
expect(frame).toContain("outputSchema.properties.data");
expect(frame).toContain("Fix:");
});
it("uses the warning glyph when nothing is error-severity", () => {
const warned = makeTool({
name: "warned",
description: undefined,
inputSchema: {
type: "object",
properties: { a: { type: ["null", "boolean"] } },
},
} as Partial<Tool>);
const { lastFrame } = render(
<ToolsTab
tools={[warned]}
isConnected={true}
width={140}
height={30}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("warned ?");
expect(frame).toContain("Schema Portability (1)");
});
});
});
describe("schemaMarker", () => {
it("returns no glyph for undefined or empty findings", () => {
expect(schemaMarker(undefined)).toBeUndefined();
expect(schemaMarker([])).toBeUndefined();
});
it("prefers the error glyph when both severities are present", () => {
expect(
schemaMarker([
{
rule: "type-union",
severity: "warning",
schema: "inputSchema",
path: "",
issue: "i",
suggestion: "s",
},
{
rule: "boolean-schema",
severity: "error",
schema: "inputSchema",
path: "",
issue: "i",
suggestion: "s",
},
]),
).toEqual({ glyph: "!", color: "red" });
});
});

View file

@ -1,9 +1,43 @@
import React, { useState, useEffect, useRef } from "react";
import React, { useState, useEffect, useMemo, useRef } from "react";
import { Box, Text, useInput, type Key } from "ink";
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
import type { Tool } from "@modelcontextprotocol/client";
import {
describeSchemaPath,
lintToolSchemas,
type SchemaFinding,
} from "@inspector/core/json/schemaLint.js";
import { useSelectableList } from "../hooks/useSelectableList.js";
/**
* How each severity renders in the terminal (#1005). One table, read by both
* the list row's flag and the detail block's heading, so the two can never
* disagree about what a severity looks like.
*/
const SEVERITY_MARKER = {
error: { glyph: "!", color: "red" },
warning: { glyph: "?", color: "yellow" },
} as const satisfies Record<
SchemaFinding["severity"],
{ glyph: string; color: string }
>;
/**
* The one-glyph flag a tool row carries when its schemas have portability
* findings: `!` in red when something is refused outright by a real client,
* `?` in yellow when it is merely handled unevenly. `undefined` — no glyph at
* all — for a clean tool, which is the overwhelming majority, so the list
* stays quiet unless there is something to say.
*/
export function schemaMarker(
findings: readonly SchemaFinding[] | undefined,
): { glyph: string; color: string } | undefined {
if (!findings || findings.length === 0) return undefined;
return findings.some((f) => f.severity === "error")
? SEVERITY_MARKER.error
: SEVERITY_MARKER.warning;
}
interface ToolsTabProps {
tools: Tool[];
isConnected: boolean;
@ -33,6 +67,13 @@ export function ToolsTab({
{ resetWhen: tools },
);
const [error] = useState<string | null>(null);
// Tool-schema portability findings, one entry per tool, same order (#1005).
// Pure walk over data already in memory, so it is recomputed only when the
// list itself changes rather than on every keypress.
const findingsByTool = useMemo(
() => tools.map((tool) => lintToolSchemas(tool)),
[tools],
);
const scrollViewRef = useRef<ScrollViewRef>(null);
const listWidth = Math.floor(width * 0.4);
const detailWidth = width - listWidth;
@ -90,6 +131,7 @@ export function ToolsTab({
}, [selectedIndex]);
const selectedTool = tools[selectedIndex] || null;
const selectedFindings = findingsByTool[selectedIndex] ?? [];
return (
<Box flexDirection="row" width={width} height={height}>
@ -133,11 +175,15 @@ export function ToolsTab({
.map((tool, i) => {
const index = firstVisible + i;
const isSelected = index === selectedIndex;
const marker = schemaMarker(findingsByTool[index]);
return (
<Box key={tool.name || index} paddingY={0} flexShrink={0}>
<Text>
{isSelected ? "▶ " : " "}
{tool.name || `Tool ${index + 1}`}
{marker && (
<Text color={marker.color}> {marker.glyph}</Text>
)}
</Text>
</Box>
);
@ -200,6 +246,36 @@ export function ToolsTab({
</>
)}
{/* Schema portability findings (#1005) */}
{selectedFindings.length > 0 && (
<>
<Box marginTop={1} flexShrink={0}>
<Text bold color="yellow">
Schema Portability ({selectedFindings.length}):
</Text>
</Box>
{selectedFindings.map((finding, idx) => {
const marker = SEVERITY_MARKER[finding.severity];
return (
<Box
key={`finding-${finding.schema}-${finding.path}-${finding.rule}-${idx}`}
flexDirection="column"
paddingLeft={2}
marginTop={1}
flexShrink={0}
>
<Text color={marker.color}>
{marker.glyph}{" "}
{describeSchemaPath(finding.schema, finding.path)}
</Text>
<Text dimColor>{finding.issue}</Text>
<Text dimColor>Fix: {finding.suggestion}</Text>
</Box>
);
})}
</>
)}
{/* Input Schema */}
{selectedTool.inputSchema && (
<>

View file

@ -56,6 +56,27 @@ Components live under `src/components/` in four layers, smallest to largest:
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.
## Tool-schema portability (`#1005`)
The Tools tab flags tools whose advertised `inputSchema` / `outputSchema`
carries a construct that is legal JSON Schema and is refused or mishandled by
real MCP clients — the reported case being Go's `jsonschema` package emitting a
bare `true` for an `interface{}` field, which Claude Code rejects with an
opaque `"Invalid input"`.
Two surfaces, one verdict: the sidebar row (`ToolListItem`) carries a
hover-labelled icon — red when something is refused outright, yellow when it is
merely handled unevenly — and selecting the tool renders a **Schema
portability** section (`SchemaFindingsList`) above the argument form, one block
per finding with its path, the problem, and a concrete fix.
Both read [`core/json/schemaLint.ts`](../../core/json/schemaLint.ts), which is
also what backs the TUI's detail pane and the CLI's `--strict` report — so the
three clients cannot disagree about whether a schema is portable. That module's
header explains why it is a portability lint rather than a JSON Schema
validator. `test-servers/configs/unportable-schemas-http.json` is a server that
exercises every rule.
## Non-component code: `src/lib` vs `src/utils`
Two grab-bag directories, split by one rule: **`utils` = functions that compute; `lib` = things that instantiate, adapt, or touch the environment.** If it does I/O or wraps a subsystem, it's `lib`; if it's a pure transform, it's `utils`.

View file

@ -0,0 +1,86 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import type { Tool } from "@modelcontextprotocol/client";
import { lintToolSchemas } from "@inspector/core/json/schemaLint.js";
import { SchemaFindingsList } from "./SchemaFindingsList";
/** Lint a schema fixture the way the real panels do, so the stories show the
* module's own wording rather than hand-written findings that could drift. */
function findingsFor(tool: Partial<Tool>) {
return lintToolSchemas({ name: "info", ...tool } as Tool);
}
const meta: Meta<typeof SchemaFindingsList> = {
title: "Elements/SchemaFindingsList",
component: SchemaFindingsList,
};
export default meta;
type Story = StoryObj<typeof SchemaFindingsList>;
/** The reported case: Go's `jsonschema` emits `true` for an `interface{}`. */
export const BareTrueProperty: Story = {
args: {
findings: findingsFor({
outputSchema: {
type: "object",
properties: { data: true, topic: { type: "string" } },
},
} as Partial<Tool>),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("Schema portability (1)")).toBeVisible();
await expect(canvas.getByText("error")).toBeVisible();
await expect(
canvas.getByText("outputSchema.properties.data"),
).toBeVisible();
},
};
/** Warning-only: an array-form `type`, legal but read unevenly. */
export const TypeUnionWarning: Story = {
args: {
findings: findingsFor({
inputSchema: {
type: "object",
properties: { show_ids: { type: ["null", "boolean"] } },
},
} as Partial<Tool>),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("warning")).toBeVisible();
await expect(canvas.queryByText("error")).toBeNull();
},
};
/** Both severities on one tool, which is what a real problem server looks like. */
export const Mixed: Story = {
args: {
findings: findingsFor({
inputSchema: {
type: "object",
properties: {
show_ids: { type: ["null", "boolean"] },
extra: {},
ref: { $ref: "https://example.com/s.json" },
},
},
outputSchema: { type: "object", properties: { data: true } },
} as Partial<Tool>),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("Schema portability (4)")).toBeVisible();
},
};
/** The common case — nothing to say, so the section renders nothing at all. */
export const Clean: Story = {
args: { findings: [] },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.queryByTestId("schema-findings")).toBeNull();
},
};

View file

@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { screen } from "@testing-library/react";
import type { Tool } from "@modelcontextprotocol/client";
import { lintToolSchemas } from "@inspector/core/json/schemaLint.js";
import { renderWithMantine } from "../../../test/renderWithMantine";
import { SchemaFindingsList } from "./SchemaFindingsList";
function findingsFor(tool: Partial<Tool>) {
return lintToolSchemas({ name: "info", ...tool } as Tool);
}
describe("SchemaFindingsList", () => {
it("renders nothing when there are no findings", () => {
renderWithMantine(<SchemaFindingsList findings={[]} />);
expect(screen.queryByTestId("schema-findings")).not.toBeInTheDocument();
});
it("shows the path, severity, issue and fix for an error finding", () => {
renderWithMantine(
<SchemaFindingsList
findings={findingsFor({
outputSchema: { type: "object", properties: { data: true } },
} as Partial<Tool>)}
/>,
);
expect(screen.getByText("Schema portability (1)")).toBeInTheDocument();
expect(screen.getByText("error")).toBeInTheDocument();
expect(
screen.getByText("outputSchema.properties.data"),
).toBeInTheDocument();
expect(screen.getByText(/Bare `true`/)).toBeInTheDocument();
expect(screen.getByText(/^Fix: /)).toBeInTheDocument();
});
it("labels a warning finding as such", () => {
renderWithMantine(
<SchemaFindingsList
findings={findingsFor({
inputSchema: {
type: "object",
properties: { a: { type: ["null", "boolean"] } },
},
} as Partial<Tool>)}
/>,
);
expect(screen.getByText("warning")).toBeInTheDocument();
expect(screen.queryByText("error")).not.toBeInTheDocument();
});
it("renders one block per finding", () => {
renderWithMantine(
<SchemaFindingsList
findings={findingsFor({
inputSchema: { type: "object", properties: { a: true, b: true } },
} as Partial<Tool>)}
/>,
);
expect(screen.getByText("Schema portability (2)")).toBeInTheDocument();
expect(screen.getAllByText("error")).toHaveLength(2);
});
});

View file

@ -0,0 +1,103 @@
import { Code, Group, Stack, Text } from "@mantine/core";
import {
describeSchemaPath,
type SchemaFinding,
} from "@inspector/core/json/schemaLint.js";
// Section wrapper: heading + one block per finding.
const FindingsSection = Stack.withProps({
gap: "xs",
});
const FindingsTitle = Text.withProps({
size: "sm",
fw: 600,
});
const FindingsNote = Text.withProps({
size: "xs",
c: "var(--inspector-text-secondary)",
});
// One finding: severity badge + path on the first row, then issue and fix.
const FindingBlock = Stack.withProps({
gap: 2,
});
const FindingHeadRow = Group.withProps({
gap: "xs",
wrap: "nowrap",
align: "center",
});
const FindingText = Text.withProps({
size: "xs",
c: "var(--inspector-text-secondary)",
});
/**
* Severity label.
*
* Deliberately coloured text rather than a filled `Badge`: a filled `yellow`
* badge puts white on `yellow-7`, which at this size is 3.92:1 and fails the
* story's a11y check, and neither `autoContrast` (yellow-7 sits below
* Mantine's default luminance threshold, so it stays white) nor
* `variant="light"` (3.34:1) fixes it. The `--inspector-*` severity tokens are
* the pairings this app already uses against its own surfaces, in both colour
* schemes.
*/
const SeverityLabel = Text.withProps({
size: "xs",
fw: 700,
tt: "uppercase",
});
/**
* Severity → text-colour token. `error` is a construct a shipping MCP client
* refuses outright; `warning` is one handled unevenly.
*/
function severityColor(severity: SchemaFinding["severity"]): string {
return severity === "error"
? "var(--inspector-danger-text)"
: "var(--inspector-warning-text)";
}
export interface SchemaFindingsListProps {
/** Findings for one tool, in walk order. Renders nothing when empty. */
findings: readonly SchemaFinding[];
}
/**
* Tool-schema portability findings for one tool (#1005).
*
* The same verdict the CLI's `--strict` report and the TUI's detail pane show
* — all three read `core/json/schemaLint`, so they cannot disagree about
* whether a schema is portable, only about how much room they have to say so.
*/
export function SchemaFindingsList({ findings }: SchemaFindingsListProps) {
if (findings.length === 0) return null;
return (
<FindingsSection data-testid="schema-findings">
<FindingsTitle>Schema portability ({findings.length})</FindingsTitle>
{findings.map((finding, index) => (
<FindingBlock
key={`${finding.schema}-${finding.path}-${finding.rule}-${index}`}
>
<FindingHeadRow>
<SeverityLabel c={severityColor(finding.severity)}>
{finding.severity}
</SeverityLabel>
<Code>{describeSchemaPath(finding.schema, finding.path)}</Code>
</FindingHeadRow>
<FindingText>{finding.issue}</FindingText>
<FindingText>Fix: {finding.suggestion}</FindingText>
</FindingBlock>
))}
<FindingsNote>
These constructs are legal JSON Schema but are refused or mishandled by
some MCP clients, so a tool can work here and fail there.
</FindingsNote>
</FindingsSection>
);
}

View file

@ -510,6 +510,30 @@ describe("ToolDetailPanel", () => {
});
});
describe("schema portability findings (#1005)", () => {
it("lists a finding with its path when a schema is unportable", () => {
renderWithMantine(
<ToolDetailPanel
{...baseProps}
tool={{
name: "info",
inputSchema: { type: "object", properties: {} },
outputSchema: { type: "object", properties: { data: true } },
}}
/>,
);
expect(screen.getByText("Schema portability (1)")).toBeInTheDocument();
expect(
screen.getByText("outputSchema.properties.data"),
).toBeInTheDocument();
});
it("omits the section for a portable tool", () => {
renderWithMantine(<ToolDetailPanel {...baseProps} tool={simpleTool} />);
expect(screen.queryByTestId("schema-findings")).not.toBeInTheDocument();
});
});
// #2020: Execute used to be gated on `isExecuting` alone — this panel never
// called `hasMissingRequiredFields`, unlike the App and elicitation panels —
// so arguments that could not be sent were executed anyway and simply arrived

View file

@ -11,7 +11,7 @@ import {
Switch,
Text,
} from "@mantine/core";
import { useId, useState } from "react";
import { useId, useMemo, useState } from "react";
import { RiArrowDownSLine, RiArrowRightSLine } from "react-icons/ri";
import type {
ProgressNotification,
@ -24,7 +24,9 @@ import {
toFormSchema,
} from "../../../utils/jsonUtils";
import { getMirroredHeaderParams } from "@inspector/core/json/xMcpHeader.js";
import { lintToolSchemas } from "@inspector/core/json/schemaLint.js";
import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge";
import { SchemaFindingsList } from "../../elements/SchemaFindingsList/SchemaFindingsList";
import { ProgressDisplay } from "../../elements/ProgressDisplay/ProgressDisplay";
import { SchemaForm } from "../SchemaForm/SchemaForm";
@ -219,6 +221,9 @@ export function ToolDetailPanel({
// SEP-2243: args this tool declares as `x-mcp-header` — their values mirror
// into `Mcp-Param-{Name}` headers on a `tools/call` (#1632).
const mirroredParams = getMirroredHeaderParams(tool);
// Memoized on the tool: this panel re-renders on every keystroke in the
// argument form, and the walk depends on nothing that changes in between.
const schemaFindings = useMemo(() => lintToolSchemas(tool), [tool]);
// Descriptions are shown by default (most are short); the chevron lets the
// user hide a long one to keep the form and Execute footer in view. Reset to
@ -338,6 +343,8 @@ export function ToolDetailPanel({
</HeaderParamsSection>
)}
<SchemaFindingsList findings={schemaFindings} />
<SchemaForm
schema={formSchema}
values={formValues}

View file

@ -64,4 +64,48 @@ describe("ToolListItem", () => {
);
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
});
it("shows no schema flag for a clean tool", () => {
renderWithMantine(
<ToolListItem tool={tool} selected={false} onClick={() => {}} />,
);
expect(
screen.queryByLabelText(/Schema portability/),
).not.toBeInTheDocument();
});
it("flags a tool whose schema carries an error-severity finding", () => {
renderWithMantine(
<ToolListItem
tool={{
...tool,
outputSchema: { type: "object", properties: { data: true } },
}}
selected={false}
onClick={() => {}}
/>,
);
expect(
screen.getByLabelText("Schema portability errors"),
).toBeInTheDocument();
});
it("flags a warning-only tool distinctly", () => {
renderWithMantine(
<ToolListItem
tool={{
...tool,
inputSchema: {
type: "object",
properties: { a: { type: ["null", "boolean"] } },
},
}}
selected={false}
onClick={() => {}}
/>,
);
expect(
screen.getByLabelText("Schema portability warnings"),
).toBeInTheDocument();
});
});

View file

@ -1,5 +1,16 @@
import { Group, Image, Stack, Text, UnstyledButton } from "@mantine/core";
import {
Group,
Image,
Stack,
Text,
ThemeIcon,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { useMemo } from "react";
import { RiErrorWarningLine } from "react-icons/ri";
import type { Tool } from "@modelcontextprotocol/client";
import { lintToolSchemas } from "@inspector/core/json/schemaLint.js";
import { resolveDisplayLabel } from "../../../utils/toolUtils";
export interface ToolListItemProps {
@ -43,9 +54,20 @@ const ListItemButton = UnstyledButton.withProps({
variant: "listItem",
});
// Schema-portability flag (#1005). Sits in the row rather than only in the
// detail panel so a problem tool is visible without clicking each one — the
// findings are rare enough that a marked row means something.
const SchemaFlagIcon = ThemeIcon.withProps({
size: "xs",
variant: "transparent",
role: "img",
});
export function ToolListItem({ tool, selected, onClick }: ToolListItemProps) {
const { name, title, icons } = tool;
const iconSrc = icons?.[0]?.src;
const findings = useMemo(() => lintToolSchemas(tool), [tool]);
const hasError = findings.some((f) => f.severity === "error");
return (
<ListItemButton
@ -58,6 +80,23 @@ export function ToolListItem({ tool, selected, onClick }: ToolListItemProps) {
<ItemLabel>{resolveDisplayLabel(name, title)}</ItemLabel>
{title && <ItemSubLabel>{name}</ItemSubLabel>}
</ItemBody>
{findings.length > 0 && (
<Tooltip
label={`${findings.length} schema portability finding${findings.length === 1 ? "" : "s"} — select the tool for detail`}
withArrow
>
<SchemaFlagIcon
color={hasError ? "red" : "yellow"}
aria-label={
hasError
? "Schema portability errors"
: "Schema portability warnings"
}
>
<RiErrorWarningLine />
</SchemaFlagIcon>
</Tooltip>
)}
</Row>
</ListItemButton>
);

Some files were not shown because too many files have changed in this diff Show more