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
5 changed files with 1979 additions and 13 deletions

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`);
}