fix: address Copilot review on the schema portability lint

- Scope `non-object-root` to `inputSchema`. The pinned SDK types
  `inputSchema` with `type: literal("object")` but `outputSchema` as a
  bare `looseObject`, so a `{"type":"string"}` output is conforming; the
  rule was claiming a requirement MCP does not make, at error severity.

- Decide `untyped-schema` from an allowlist of constraining keywords
  rather than a denylist of annotations. JSON Schema ignores keywords it
  does not recognize, so `{"vendorHint": true}` accepts every value and a
  denylist passed it as constrained. `$defs`-only schemas now flag too.

- Walk `dependentSchemas` and draft-07 `dependencies`, with a test
  pinning that the property-name-array form is not a schema position.

- Suggest `anyOf` branches for an array-form `type`, never "drop it from
  `required`" — accepting `null` and permitting absence are independent
  contracts, so the old suggestion silently changed the schema's meaning.

- Reject `--strict` alongside `--app-info`. That path returns NDJSON from
  `runMethod` and never reaches the lint, so the flag was a no-op gate
  that could never fail.

- Colour the web sidebar flag from the `--inspector-*` severity tokens,
  matching the detail panel and avoiding the default yellow that failed
  the a11y check.

- Add a live integration test for `rawToolSchemas`. It caught that an
  `outputSchema` override on a preset returning no structured content
  makes every call to that tool fail client-side validation — the
  showcase config did exactly that, so the flagged tool could not be
  run. Moved the bare `true` onto `get_temp`, documented the caveat, and
  pinned both halves.

- Drop the double cast from the lint test fixture.

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:41:12 -04:00
commit c389565783

View file

@ -0,0 +1,244 @@
import { describe, it, expect, afterEach } from "vitest";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
import { lintTools } from "@inspector/core/json/schemaLint.js";
import {
createTestServerHttp,
type TestServerHttp,
createTestServerInfo,
createEchoTool,
createGetWeatherTool,
createGetTempTool,
} from "@modelcontextprotocol/inspector-test-server";
import type { ServerConfig } from "@modelcontextprotocol/inspector-test-server";
/**
* Live coverage of `ServerConfig.rawToolSchemas` (#1005) — the only way this
* repo can advertise a tool schema containing a bare `true`, an array-form
* `type`, or a remote `$ref`, since every preset builds its schema from Zod
* and Zod cannot emit any of them.
*
* The lint itself is unit-tested against hand-built `Tool` objects, which
* bypasses this path entirely: handler installation, config resolution, and
* the interaction with pagination and duplication could all regress while
* those stayed green. This file drives the real server over a real transport
* and asserts on what actually reaches the wire.
*/
describe("rawToolSchemas override (#1005)", () => {
let client: InspectorClient | null = null;
let server: TestServerHttp | null = null;
afterEach(async () => {
if (client) {
try {
await client.disconnect();
} catch {
// ignore
}
client = null;
}
if (server) {
try {
await server.stop();
} catch {
// ignore
}
server = null;
}
});
async function start(config: Partial<ServerConfig>): Promise<TestServerHttp> {
const started = createTestServerHttp({
serverInfo: createTestServerInfo("raw-tool-schemas-test", "1.0.0"),
tools: [createEchoTool(), createGetWeatherTool()],
...config,
});
await started.start();
server = started;
return started;
}
async function connect(url: string): Promise<InspectorClient> {
const connected = new InspectorClient(
{ type: "streamable-http", url },
{ environment: { transport: createTransportNode } },
);
await connected.connect();
client = connected;
return connected;
}
it("advertises the raw document, and the lint finds it end to end", async () => {
const started = await start({
rawToolSchemas: {
echo: {
inputSchema: {
type: "object",
properties: {
message: { type: "string" },
show_ids: { type: ["null", "boolean"] },
},
},
outputSchema: {
type: "object",
properties: { data: true },
},
},
},
});
const connected = await connect(started.url);
const { tools } = await connected.listAllTools();
const echo = tools.find((t) => t.name === "echo");
// The bare `true` survives the wire — a Zod-built schema could not
// produce it, so its presence here proves the override is what is served.
expect(echo?.outputSchema).toEqual({
type: "object",
properties: { data: true },
});
expect(
(echo?.inputSchema as { properties?: Record<string, unknown> }).properties
?.show_ids,
).toEqual({ type: ["null", "boolean"] });
const results = lintTools(tools);
expect(results.map((r) => r.toolName)).toEqual(["echo"]);
expect(results[0]?.findings.map((f) => f.rule).sort()).toEqual([
"boolean-schema",
"type-union",
]);
});
it("leaves an unnamed tool's real schema alone", async () => {
const started = await start({
rawToolSchemas: { echo: { outputSchema: { type: "object" } } },
});
const connected = await connect(started.url);
const { tools } = await connected.listAllTools();
const weather = tools.find((t) => t.name === "get_weather");
// get_weather keeps the Zod-derived schema, which is portable.
expect(weather?.inputSchema).toMatchObject({ type: "object" });
expect(lintTools(tools)).toEqual([]);
});
it("replaces only the schema named, leaving the sibling untouched", async () => {
const started = await start({
rawToolSchemas: {
echo: { outputSchema: { type: "object", properties: { data: true } } },
},
});
const connected = await connect(started.url);
const { tools } = await connected.listAllTools();
const echo = tools.find((t) => t.name === "echo");
// Only `outputSchema` was overridden, so the Zod-built input survives.
expect(echo?.inputSchema).toMatchObject({ type: "object" });
expect(
(echo?.inputSchema as { properties?: Record<string, unknown> })
.properties,
).toHaveProperty("message");
});
it("ignores a name that is not registered", async () => {
const started = await start({
rawToolSchemas: { not_a_tool: { inputSchema: true } },
});
const connected = await connect(started.url);
const { tools } = await connected.listAllTools();
expect(tools.map((t) => t.name)).toEqual(["echo", "get_weather"]);
expect(lintTools(tools)).toEqual([]);
});
it("still calls the tool when only the input schema was overridden", async () => {
const started = await start({
rawToolSchemas: {
echo: {
inputSchema: {
type: "object",
properties: { message: { type: "string" }, opts: {} },
},
},
},
});
const connected = await connect(started.url);
// The handler and its real Zod validation are untouched by the override —
// the fixture would be misleading if the tool it flags could not be run.
const { tools } = await connected.listAllTools();
const echo = tools.find((t) => t.name === "echo")!;
const result = await connected.callTool(echo, { message: "hi" });
expect(JSON.stringify(result)).toContain("hi");
});
it("makes the call fail when an outputSchema is put on a tool with no structured content", async () => {
// Pinning the sharp edge documented on `ServerConfig.rawToolSchemas`: a
// conforming client validates results against the advertised output
// schema, so an override on a preset that returns none breaks every call.
// This is why the showcase config puts its bare-`true` outputSchema on
// `get_temp` (which does return structured content) rather than on `echo`.
const started = await start({
rawToolSchemas: {
echo: { outputSchema: { type: "object", properties: { data: true } } },
},
});
const connected = await connect(started.url);
const { tools } = await connected.listAllTools();
const echo = tools.find((t) => t.name === "echo")!;
await expect(connected.callTool(echo, { message: "hi" })).rejects.toThrow(
/returned no structured content/,
);
});
it("keeps a structured-content tool callable under an outputSchema override", async () => {
// The shape the showcase config actually ships: the bare `true` rides a
// tool that returns structured content, and the override stays permissive
// enough (no `required`) that the real payload still validates — so the
// flagged tool is still runnable from the Tools tab.
const started = await start({
tools: [createGetTempTool(), createEchoTool()],
rawToolSchemas: {
get_temp: {
outputSchema: {
type: "object",
properties: { data: true, city: { type: "string" } },
},
},
},
});
const connected = await connect(started.url);
const { tools } = await connected.listAllTools();
const getTemp = tools.find((t) => t.name === "get_temp")!;
expect(lintTools(tools).map((r) => r.toolName)).toEqual(["get_temp"]);
const result = await connected.callTool(getTemp, {
city: "Oslo",
units: "C",
});
expect(JSON.stringify(result)).toContain("Oslo");
});
it("applies before duplication, so both copies carry the raw schema", async () => {
const started = await start({
duplicateToolNames: ["echo"],
rawToolSchemas: {
echo: { outputSchema: { type: "object", properties: { data: true } } },
},
});
const connected = await connect(started.url);
const { tools } = await connected.listAllTools();
const echoes = tools.filter((t) => t.name === "echo");
expect(echoes).toHaveLength(2);
for (const e of echoes) {
expect(e.outputSchema).toEqual({
type: "object",
properties: { data: true },
});
}
});
});