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:
parent
2b129087a3
commit
2f9e0901c1
4 changed files with 1979 additions and 13 deletions
39
test-servers/configs/unportable-schemas-http.json
Normal file
39
test-servers/configs/unportable-schemas-http.json
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue