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

View file

@ -0,0 +1,39 @@
{
"serverInfo": {
"name": "unportable-schemas-showcase",
"version": "1.0.0"
},
"tools": [{ "preset": "echo" }, { "preset": "add" }, { "preset": "get_temp" }],
"rawToolSchemas": {
"echo": {
"inputSchema": {
"type": "object",
"properties": {
"message": { "type": "string" },
"show_ids": { "type": ["null", "boolean"] },
"opts": {}
}
},
"outputSchema": {
"type": "object",
"properties": {
"data": true,
"topic": { "type": "string" }
}
}
},
"add": {
"inputSchema": {
"type": "object",
"properties": {
"a": { "$ref": "https://example.com/schemas/number.json" },
"b": { "type": "number" }
}
}
}
},
"transport": {
"type": "streamable-http",
"port": 6603
}
}

View file

@ -495,6 +495,24 @@ export interface ServerConfig {
* ignored.
*/
duplicateToolNames?: string[];
/**
* Replace a registered tool's `inputSchema` / `outputSchema` in `tools/list`
* with a **raw** JSON Schema document (#1005).
*
* The presets build their schemas from Zod, which by construction cannot
* emit the constructs the schema-portability lint exists to find — a bare
* `true` in a `properties` map, an array-form `type`, a remote `$ref`. Those
* come from other generators (Go's `jsonschema` package emits `true` for
* `interface{}`), so reproducing them needs a hand-written document. Only the
* *advertised* schema is replaced; the tool's handler and its real Zod
* validation are untouched, so calling it still behaves as its preset does.
*
* A name that isn't registered is ignored.
*/
rawToolSchemas?: Record<
string,
{ inputSchema?: unknown; outputSchema?: unknown }
>;
/**
* Gate a tool's visibility in `tools/list` on a client-declared extension
* (SEP-2133 `capabilities.extensions`). Maps extension id → tool name (the
@ -1186,11 +1204,34 @@ export function createMcpServer(config: ServerConfig): McpServer {
})),
];
// Tools pagination, and/or the duplicate-name override — both need the same
// hand-built list, so the handler is installed when either is configured.
// Swap in hand-written JSON Schema documents for the named tools (#1005).
// Applied before duplication so a duplicated row carries the same schema its
// twin does, which is what a real concatenated tool list looks like.
const rawToolSchemas = config.rawToolSchemas ?? {};
const withRawSchemas = (tools: Tool[]): Tool[] =>
Object.keys(rawToolSchemas).length === 0
? tools
: tools.map((tool) => {
const override = rawToolSchemas[tool.name];
if (!override) return tool;
const patched: Record<string, unknown> = { ...tool };
if (override.inputSchema !== undefined) {
patched.inputSchema = override.inputSchema;
}
if (override.outputSchema !== undefined) {
patched.outputSchema = override.outputSchema;
}
return patched as Tool;
});
// Tools pagination, the duplicate-name override, and the raw-schema override
// all need the same hand-built list, so the handler is installed when any of
// them is configured.
if (
capabilities.tools &&
(maxPageSize.tools !== undefined || duplicateToolNames.size > 0)
(maxPageSize.tools !== undefined ||
duplicateToolNames.size > 0 ||
Object.keys(rawToolSchemas).length > 0)
) {
mcpServer.server.setRequestHandler("tools/list", async (request) => {
const cursor = request.params?.cursor;
@ -1224,7 +1265,7 @@ export function createMcpServer(config: ServerConfig): McpServer {
}
// Duplicate before paginating, so a duplicated pair can straddle a page
// boundary exactly as a real server's would.
const allTools = withDuplicates(registeredTools);
const allTools = withDuplicates(withRawSchemas(registeredTools));
const startIndex = cursor ? parseInt(cursor, 10) : 0;
const endIndex = startIndex + pageSize;

View file

@ -79,6 +79,15 @@ export interface ConfigFile {
* no preset can produce. See {@link ServerConfig.duplicateToolNames} (#1957).
*/
duplicateToolNames?: string[];
/**
* Replace a registered tool's advertised `inputSchema`/`outputSchema` with a
* raw JSON Schema document — the constructs a Zod-built preset cannot emit.
* See {@link ServerConfig.rawToolSchemas} (#1005).
*/
rawToolSchemas?: Record<
string,
{ inputSchema?: unknown; outputSchema?: unknown }
>;
/**
* Gate a tool's `tools/list` visibility on a client-declared extension. Maps
* extension id → tool name; the tool appears only when the connected client

View file

@ -93,6 +93,7 @@ export function resolveConfig(config: ConfigFile): ServerConfig {
appElicitation: config.appElicitation,
maxPageSize: config.maxPageSize,
duplicateToolNames: config.duplicateToolNames,
rawToolSchemas: config.rawToolSchemas,
extensionGatedTools: config.extensionGatedTools,
serverType: isHttp
? (transport.type as "sse" | "streamable-http")