Merge branch 'v2/main' into v2/fix/2117-persist-requested-scope
This commit is contained in:
commit
6e88797416
31 changed files with 3230 additions and 27 deletions
22
AGENTS.md
22
AGENTS.md
|
|
@ -157,7 +157,27 @@ 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 constraining
|
||||
│ │ # keyword). 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. Equally deliberately it has NO
|
||||
│ │ # "inputSchema must be an object" rule, even though
|
||||
│ │ # MCP requires that: the SDK types inputSchema with
|
||||
│ │ # `type: literal("object")`, so such a tool fails
|
||||
│ │ # ListToolsResultSchema and salvageListItems drops it
|
||||
│ │ # before any client sees it — a rule that cannot fire
|
||||
│ │ # is worse than none, because the docs then claim a
|
||||
│ │ # check the tool does not perform. 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.
|
||||
|
|
|
|||
62
README.md
62
README.md
|
|
@ -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,62 @@ 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 four tools, three of whose advertised
|
||||
schemas carry constructs that are legal JSON Schema and are refused or
|
||||
mishandled by real MCP clients:
|
||||
|
||||
| Tool | What it carries |
|
||||
| --- | --- |
|
||||
| `get_temp` | `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) |
|
||||
| `echo` | an array-form `"type": ["null","boolean"]`, and an `opts` property that constrains nothing |
|
||||
| `add` | a property pointing at a remote `$ref` |
|
||||
| `get_weather` | nothing — left clean, so a flagged tool sits beside an unflagged one |
|
||||
|
||||
Plain streamable-HTTP — connect with the **default (legacy)** protocol era.
|
||||
Every tool here is still **runnable**: the override replaces only the
|
||||
*advertised* schema, and the flagship bare-`true` rides `get_temp` (which
|
||||
returns structured content) rather than `echo` — see the ⚠️ caveat at the end
|
||||
of this section for why that placement matters.
|
||||
|
||||
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 `get_temp` with a red `!` and `echo`/`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.
|
||||
|
||||
⚠️ **If you add an `outputSchema` override of your own, put it on a tool that
|
||||
returns structured content.** A conforming client validates a tool result
|
||||
against the advertised output schema, so an override on a preset that returns
|
||||
none makes every call to it fail with "declares an output schema but returned
|
||||
no structured content" — a confusing thing to hit from a fixture. That is why
|
||||
the bare `true` rides `get_temp` here and not `echo`.
|
||||
|
||||
#### 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.
|
||||
|
|
|
|||
|
|
@ -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,84 @@ 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
|
||||
```
|
||||
|
||||
The complete **report** against the `unportable-schemas-http.json` showcase —
|
||||
one block per finding, then the summary. On a non-zero exit the shared error
|
||||
handler adds one more stderr line after this, the
|
||||
[`ErrorEnvelope`](#exit-codes--error-envelopes) (`{"error":{"code":"schema_unportable",…}}`):
|
||||
|
||||
```text
|
||||
Error: tool "get_temp"
|
||||
Path: outputSchema.properties.data
|
||||
Issue: Bare `true` used where a schema object is expected.
|
||||
Suggestion: Declare what the value actually is — e.g. `{"type": "object", "additionalProperties": true}` for a free-form object. That is a deliberate change of contract, not an equivalent rewrite: `true` accepts any JSON value at all.
|
||||
|
||||
Warning: tool "echo"
|
||||
Path: inputSchema.properties.show_ids
|
||||
Issue: `type` is an array (["null","boolean"]). The array form is legal JSON Schema, but several MCP clients read `type` as a single string and either reject the tool or drop the constraint.
|
||||
Suggestion: Split it into `anyOf` branches, each with a single `type` — `{"anyOf": [{"type": "null"}, {"type": "boolean"}]}`. (Making the property optional instead is a different contract: absent is not the same as `null`.)
|
||||
|
||||
Warning: tool "echo"
|
||||
Path: inputSchema.properties.opts
|
||||
Issue: Schema carries no validation keyword at all, so it accepts any value — the object-literal spelling of a bare `true`.
|
||||
Suggestion: Declare what the value actually is — e.g. `{"type": "object", "additionalProperties": true}` for a free-form object. That is a deliberate change of contract, which is the point: as written it constrains nothing.
|
||||
|
||||
Warning: tool "add"
|
||||
Path: inputSchema.properties.a
|
||||
Issue: `$ref` points outside this document (`https://example.com/schemas/number.json`). Clients do not fetch remote schemas, so the constraint is dropped or the tool is rejected.
|
||||
Suggestion: Inline the referenced schema, or move it into `$defs` and reference it as `#/$defs/<name>`.
|
||||
|
||||
1 error, 3 warnings across 3 tools.
|
||||
```
|
||||
|
||||
Note the shape of that suggestion. Where a replacement genuinely narrows the
|
||||
schema it says so, rather than implying an equivalent rewrite — and where an
|
||||
equivalent exists it gives that instead: an array-form `type` becomes `anyOf`
|
||||
branches (not "drop it from `required`", since absent is not the same as
|
||||
`null`), and a bare `false` becomes `{"not": {}}` (not "delete the entry",
|
||||
which would *permit* the property under the default `additionalProperties`).
|
||||
|
||||
Severity decides the exit code, not the report: **errors** are constructs a
|
||||
shipping MCP client refuses outright (a bare `true` or `false` where a schema
|
||||
object belongs), **warnings** are ones handled unevenly (an array-form `type`,
|
||||
a remote `$ref`, a schema carrying no constraining 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.
|
||||
|
||||
There is deliberately **no "inputSchema must be an object" check**, even though
|
||||
MCP requires one. The SDK types `inputSchema` with `type: literal("object")`,
|
||||
so a tool whose input root is anything else fails `ListToolsResultSchema` and
|
||||
is dropped from the list before the lint could see it — it is reported through
|
||||
the malformed-items path instead. A rule that cannot fire would only make this
|
||||
documentation claim a check the CLI does not perform.
|
||||
|
||||
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 3 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 +329,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 (`schema_unportable` — the schema is valid JSON Schema, just not portable). |
|
||||
|
||||
On any non-zero exit the CLI also writes a single JSON line to **stderr** — the
|
||||
`ErrorEnvelope`:
|
||||
|
|
|
|||
|
|
@ -19,15 +19,19 @@ describe("handleError", () => {
|
|||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("emits a JSON error envelope on stderr and exits with the classified code", () => {
|
||||
const writeSpy = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation((() => true) as never);
|
||||
it("emits a JSON error envelope on stderr and exits with the classified code", async () => {
|
||||
const writeSpy = vi.spyOn(process.stderr, "write").mockImplementation(((
|
||||
_chunk: unknown,
|
||||
cb?: () => void,
|
||||
) => {
|
||||
cb?.();
|
||||
return true;
|
||||
}) as never);
|
||||
const exitSpy = vi
|
||||
.spyOn(process, "exit")
|
||||
.mockImplementation((() => undefined) as never);
|
||||
|
||||
handleError(new Error("boom"));
|
||||
await handleError(new Error("boom"));
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(EXIT_CODES.USAGE);
|
||||
const written = writeSpy.mock.calls[0]![0] as string;
|
||||
|
|
@ -38,18 +42,49 @@ describe("handleError", () => {
|
|||
expect(parsed.error.message).toBe("boom");
|
||||
});
|
||||
|
||||
it("uses a CliExitCodeError's exitCode and envelope code", () => {
|
||||
vi.spyOn(process.stderr, "write").mockImplementation((() => true) as never);
|
||||
it("uses a CliExitCodeError's exitCode and envelope code", async () => {
|
||||
vi.spyOn(process.stderr, "write").mockImplementation(((
|
||||
_chunk: unknown,
|
||||
cb?: () => void,
|
||||
) => {
|
||||
cb?.();
|
||||
return true;
|
||||
}) as never);
|
||||
const exitSpy = vi
|
||||
.spyOn(process, "exit")
|
||||
.mockImplementation((() => undefined) as never);
|
||||
|
||||
handleError(
|
||||
await handleError(
|
||||
new CliExitCodeError(EXIT_CODES.NO_APP, "no app", { code: "no_app" }),
|
||||
);
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(EXIT_CODES.NO_APP);
|
||||
});
|
||||
|
||||
it("exits only after stderr has taken the envelope", async () => {
|
||||
// The whole point of the await: a pipe defers the write, and
|
||||
// `process.exit()` discards anything still queued. A spy that called back
|
||||
// synchronously would pass with or without it, so this one defers.
|
||||
let flushed = false;
|
||||
vi.spyOn(process.stderr, "write").mockImplementation(((
|
||||
_chunk: unknown,
|
||||
cb?: () => void,
|
||||
) => {
|
||||
setTimeout(() => {
|
||||
flushed = true;
|
||||
cb?.();
|
||||
}, 0);
|
||||
return false;
|
||||
}) as never);
|
||||
const exitSpy = vi
|
||||
.spyOn(process, "exit")
|
||||
.mockImplementation((() => undefined) as never);
|
||||
|
||||
await handleError(new Error("boom"));
|
||||
|
||||
expect(flushed).toBe(true);
|
||||
expect(exitSpy).toHaveBeenCalledWith(EXIT_CODES.USAGE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyError", () => {
|
||||
|
|
@ -64,6 +99,18 @@ describe("classifyError", () => {
|
|||
expect(envelope.url).toBe("https://x.example/mcp");
|
||||
});
|
||||
|
||||
it("derives schema_unportable from the exit code when no envelope is given", () => {
|
||||
// `CliExitCodeError` lets a caller omit the envelope, in which case the
|
||||
// machine code comes from `codeForExit`. A new exit code that is missing
|
||||
// there degrades to the generic `error`, which is exactly the kind of
|
||||
// silent mislabelling the code map exists to prevent (#1005).
|
||||
const { exitCode, envelope } = classifyError(
|
||||
new CliExitCodeError(EXIT_CODES.SCHEMA_UNPORTABLE, "1 finding"),
|
||||
);
|
||||
expect(exitCode).toBe(EXIT_CODES.SCHEMA_UNPORTABLE);
|
||||
expect(envelope.code).toBe("schema_unportable");
|
||||
});
|
||||
|
||||
it("classifies a WWW-Authenticate message as AUTH_REQUIRED without a status", () => {
|
||||
const { exitCode } = classifyError(
|
||||
new Error("Dynamic client registration failed: WWW-Authenticate Bearer"),
|
||||
|
|
|
|||
318
clients/cli/__tests__/schema-lint-report.test.ts
Normal file
318
clients/cli/__tests__/schema-lint-report.test.ts
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
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, ...rest: unknown[]): boolean => {
|
||||
stderr += typeof chunk === "string" ? chunk : String(chunk);
|
||||
(
|
||||
rest.find((r) => typeof r === "function") as (() => void) | undefined
|
||||
)?.();
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.stderr.write = originalWrite;
|
||||
});
|
||||
|
||||
it("writes nothing at all when there are no findings", async () => {
|
||||
await writeSchemaLintReport([], true);
|
||||
expect(stderr).toBe("");
|
||||
});
|
||||
|
||||
it("writes a one-line hint without --strict", async () => {
|
||||
await 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", async () => {
|
||||
await 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");
|
||||
});
|
||||
|
||||
it("resolves only once stderr has taken the write", async () => {
|
||||
// Both CLI exit paths call `process.exit()` as soon as the work returns,
|
||||
// which discards anything still buffered on a piped stderr. The report is
|
||||
// only safe if this settles on the write callback rather than fire-and-
|
||||
// forget, so the fake defers its callback to a later tick.
|
||||
let flushed = false;
|
||||
process.stderr.write = ((
|
||||
chunk: unknown,
|
||||
cb?: (err?: Error | null) => void,
|
||||
): boolean => {
|
||||
stderr += typeof chunk === "string" ? chunk : String(chunk);
|
||||
setTimeout(() => {
|
||||
flushed = true;
|
||||
cb?.();
|
||||
}, 0);
|
||||
return false;
|
||||
}) as typeof process.stderr.write;
|
||||
|
||||
await writeSchemaLintReport(lintListResult(DIRTY_LIST), true);
|
||||
expect(flushed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
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, ...rest: unknown[]): boolean => {
|
||||
stderr += typeof chunk === "string" ? chunk : String(chunk);
|
||||
(
|
||||
rest.find((r) => typeof r === "function") as (() => void) | undefined
|
||||
)?.();
|
||||
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_UNPORTABLE);
|
||||
// Not `schema_invalid`: the lint reports schemas that ARE valid JSON
|
||||
// Schema and are merely unportable, so the code an automated caller
|
||||
// branches on must not claim otherwise.
|
||||
expect(e.envelope?.code).toBe("schema_unportable");
|
||||
});
|
||||
// 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 () => {
|
||||
// `runCli` rejects `--strict --app-info` outright (see below), so this is
|
||||
// the defensive half: any other Node runner reusing `emitResult` gets the
|
||||
// app-info early return rather than a report interleaved with its NDJSON.
|
||||
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.");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["servers/list", ["--method", "servers/list"]],
|
||||
["--list-stored-auth", ["--method", "servers/list", "--list-stored-auth"]],
|
||||
])(
|
||||
"is rejected on the %s short-circuit path, which never reaches the lint",
|
||||
async (_label, extra) => {
|
||||
// These return from `parseArgs` before any connect, so a validation
|
||||
// placed further down would let `--strict` be accepted and ignored.
|
||||
await expect(
|
||||
runCli(["node", "cli", "--cli", "--strict", ...extra]),
|
||||
).rejects.toThrow("--strict requires --method tools/list.");
|
||||
},
|
||||
);
|
||||
|
||||
it("is rejected alongside --app-info rather than silently ignored", async () => {
|
||||
// `tools/list --app-info` returns NDJSON straight from `runMethod` and
|
||||
// never reaches `emitResult`, so accepting the pair would hand a CI caller
|
||||
// a `--strict` gate that can never fail.
|
||||
await expect(
|
||||
runCli([
|
||||
"node",
|
||||
"cli",
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--strict",
|
||||
"--app-info",
|
||||
"--server-url",
|
||||
"http://127.0.0.1:1/mcp",
|
||||
]),
|
||||
).rejects.toThrow("--strict cannot be combined with --app-info");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -824,6 +829,27 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
|
|||
}
|
||||
}
|
||||
|
||||
// `--strict` is checked HERE, ahead of every short-circuit return below
|
||||
// (`--list-stored-auth`, `--print-handoff`, `servers/list`, `servers/show`),
|
||||
// rather than beside the other method-shaped validations further down. Those
|
||||
// returns never reach the lint, so a later check would let
|
||||
// `--strict --method servers/list` succeed while silently ignoring a flag
|
||||
// documented as tools/list-only — the same "accepted but inert" failure the
|
||||
// `--app-info` pairing rejection exists to prevent.
|
||||
if (options.strict) {
|
||||
if (options.method !== "tools/list") {
|
||||
throw new Error("--strict requires --method tools/list.");
|
||||
}
|
||||
// `tools/list --app-info` returns NDJSON straight from `runMethod` and
|
||||
// never reaches `emitResult`, where the lint runs. Accepting the pair
|
||||
// would hand a CI caller a gate that can never fail.
|
||||
if (options.appInfo) {
|
||||
throw new Error(
|
||||
"--strict cannot be combined with --app-info; run tools/list twice, once for each.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// State-path precedence (getStateFilePath): MCP_INSPECTOR_OAUTH_STATE_PATH →
|
||||
// <MCP_STORAGE_DIR>/oauth.json → ~/.mcp-inspector/storage/oauth.json — the
|
||||
// same file the web backend writes, so tokens are shared across surfaces.
|
||||
|
|
@ -1004,6 +1030,10 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
|
|||
);
|
||||
}
|
||||
|
||||
// NOTE: `--strict`'s validations are deliberately NOT here — they run before
|
||||
// the short-circuit returns further up, so a `servers/*` invocation cannot
|
||||
// accept the flag and ignore it.
|
||||
|
||||
// --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 +1077,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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { awaitableError } from "./utils/awaitable-log.js";
|
||||
|
||||
/**
|
||||
* Exit-code map. Non-zero codes let an automated caller (CI, an agent) branch
|
||||
* on the failure class without regex-scraping stderr:
|
||||
|
|
@ -8,6 +10,12 @@
|
|||
* - 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
|
||||
*
|
||||
* Note 6 is `SCHEMA_UNPORTABLE`, not "invalid": the whole premise of the lint
|
||||
* is that these schemas ARE valid JSON Schema and are merely refused by some
|
||||
* clients. Calling the outcome "invalid" would misreport it to the automated
|
||||
* callers this map exists for.
|
||||
*/
|
||||
export const EXIT_CODES = {
|
||||
OK: 0,
|
||||
|
|
@ -16,6 +24,7 @@ export const EXIT_CODES = {
|
|||
AUTH_REQUIRED: 3,
|
||||
UNREACHABLE: 4,
|
||||
TOOL_ERROR: 5,
|
||||
SCHEMA_UNPORTABLE: 6,
|
||||
} as const;
|
||||
|
||||
/** Machine-readable error envelope written as one JSON line on stderr. */
|
||||
|
|
@ -184,6 +193,8 @@ function codeForExit(exitCode: number): string {
|
|||
return "unreachable";
|
||||
case EXIT_CODES.TOOL_ERROR:
|
||||
return "tool_error";
|
||||
case EXIT_CODES.SCHEMA_UNPORTABLE:
|
||||
return "schema_unportable";
|
||||
default:
|
||||
return "error";
|
||||
}
|
||||
|
|
@ -209,8 +220,19 @@ export function formatErrorOutput(
|
|||
};
|
||||
}
|
||||
|
||||
export function handleError(error: unknown): never {
|
||||
/**
|
||||
* The binary's last-resort error sink: write the envelope, then exit.
|
||||
*
|
||||
* **Async, and the await is load-bearing.** On a pipe or a file — as opposed
|
||||
* to a TTY — `process.stderr.write` is asynchronous, and `process.exit()`
|
||||
* discards whatever is still queued. Size is not the safeguard it looks like:
|
||||
* the envelope being small enough for the pipe buffer says nothing about
|
||||
* whether the write has been *performed* by the time the process goes away.
|
||||
* So this settles on the write callback first, exactly as the `--strict`
|
||||
* schema report does.
|
||||
*/
|
||||
export async function handleError(error: unknown): Promise<never> {
|
||||
const { exitCode, stderr } = formatErrorOutput(error);
|
||||
process.stderr.write(stderr);
|
||||
await awaitableError(stderr);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,26 @@ 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");
|
||||
}
|
||||
|
||||
// Awaited: the throw below (and the CLI's own exit path) reaches
|
||||
// `process.exit()` immediately, which discards anything still buffered on a
|
||||
// piped stderr.
|
||||
if (lint) await writeSchemaLintReport(lint, args.strict === true);
|
||||
|
||||
if ((result as { isError?: unknown }).isError === true) {
|
||||
throw new CliExitCodeError(
|
||||
EXIT_CODES.TOOL_ERROR,
|
||||
|
|
@ -43,4 +58,21 @@ 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) {
|
||||
// "unportable", not "invalid": these schemas are valid JSON Schema and
|
||||
// are merely refused by some clients, so an envelope code of
|
||||
// `schema_invalid` would tell an automated caller the wrong thing.
|
||||
throw new CliExitCodeError(
|
||||
EXIT_CODES.SCHEMA_UNPORTABLE,
|
||||
`${errors} tool schema portability error${errors === 1 ? "" : "s"} found (--strict).`,
|
||||
{ code: "schema_unportable" },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
63
clients/cli/src/handlers/schema-lint-report.ts
Normal file
63
clients/cli/src/handlers/schema-lint-report.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import type { Tool } from "@modelcontextprotocol/client";
|
||||
import {
|
||||
formatSchemaLintReport,
|
||||
lintTools,
|
||||
summarizeFindings,
|
||||
type ToolSchemaFindings,
|
||||
} from "@inspector/core/json/schemaLint.js";
|
||||
import { awaitableError } from "../utils/awaitable-log.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 async function writeSchemaLintReport(
|
||||
results: readonly ToolSchemaFindings[],
|
||||
strict: boolean,
|
||||
): Promise<void> {
|
||||
if (results.length === 0) return;
|
||||
if (!strict) {
|
||||
await awaitableError(
|
||||
`Schema portability: ${summarizeFindings(results)}. Re-run with --strict for details.\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await awaitableError(`${formatSchemaLintReport(results)}\n`);
|
||||
}
|
||||
|
|
@ -5,3 +5,21 @@ export function awaitableLog(logValue: string): Promise<void> {
|
|||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The stderr twin of {@link awaitableLog}.
|
||||
*
|
||||
* Both CLI exit paths call `process.exit()` as soon as the work returns
|
||||
* (`src/index.ts`, and `handleError` in `src/error-handler.ts`). When stderr
|
||||
* is a pipe or a file rather than a TTY, `write` is asynchronous, so anything
|
||||
* still buffered at that moment is discarded — which for a multi-block
|
||||
* `--strict` report means a truncated or entirely missing diagnostic on
|
||||
* exactly the redirected-output runs a CI caller uses.
|
||||
*/
|
||||
export function awaitableError(logValue: string): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
process.stderr.write(logValue, () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,10 +79,12 @@ async function run(): Promise<void> {
|
|||
// this `undefined`; fall through to the generic sink with the real message
|
||||
// rather than throwing "handleError is not a function" over it.
|
||||
if (typeof handleError !== "function") throw err;
|
||||
// write-then-exit like the direct bin; the envelope is a few hundred bytes,
|
||||
// well inside the pipe buffer, so the truncation risk noted above (which is
|
||||
// about a large stdout payload) doesn't apply to this stderr line.
|
||||
handleError(err); // writes the envelope + process.exit(code); never returns
|
||||
// Write-then-exit like the direct bin — and AWAIT it. The envelope being
|
||||
// a few hundred bytes is not the safeguard it looks like: on a pipe the
|
||||
// write is asynchronous, so `process.exit()` discards it whether or not
|
||||
// it would have fitted in the buffer. `handleError` settles on the write
|
||||
// callback before exiting; skipping the await puts the truncation back.
|
||||
await handleError(err); // writes the envelope + process.exit(code)
|
||||
}
|
||||
} else {
|
||||
const { runTui } = await import(clientEntry("tui"));
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -381,5 +381,31 @@ describe("schemaToForm", () => {
|
|||
type: "string",
|
||||
});
|
||||
});
|
||||
|
||||
// #1005: the unportable-schemas showcase advertises a remote `$ref` so the
|
||||
// lint has a `remote-ref` finding to report. A `$ref`-only property falls
|
||||
// through to a string field here, which the tool's real (numeric) handler
|
||||
// then rejects — so the fixture keeps a local `type` alongside the ref and
|
||||
// the tool stays runnable. This pins the half of that contract the TUI
|
||||
// owns; the wire half is in `raw-tool-schemas.test.ts`.
|
||||
it("types a property that carries both a remote $ref and a local type", () => {
|
||||
const form = schemaToForm(
|
||||
{
|
||||
properties: {
|
||||
a: {
|
||||
type: "number",
|
||||
$ref: "https://example.com/schemas/number.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
"refWithLocalType",
|
||||
);
|
||||
// `float` is this form builder's numeric field kind — the point is that
|
||||
// it is NOT the string fallback a `$ref`-only property would get.
|
||||
expect(form.sections[0]!.fields[0]).toMatchObject({
|
||||
name: "a",
|
||||
type: "float",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue