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:
parent
2f9e0901c1
commit
c389565783
1 changed files with 566 additions and 66 deletions
|
|
@ -103,37 +103,97 @@ const SUBSCHEMA_ARRAY_KEYWORDS = [
|
|||
"prefixItems",
|
||||
] as const;
|
||||
|
||||
/** Keywords whose value is a name → subschema map. */
|
||||
/**
|
||||
* Keywords whose value is a name → subschema map.
|
||||
*
|
||||
* `dependencies` is the pre-2019 form and may hold either a subschema or an
|
||||
* array of property names; the array case is not a schema position, and the
|
||||
* walk skips it because a non-object, non-boolean node is not a schema.
|
||||
*/
|
||||
const SUBSCHEMA_MAP_KEYWORDS = [
|
||||
"properties",
|
||||
"patternProperties",
|
||||
"dependentSchemas",
|
||||
"dependencies",
|
||||
"$defs",
|
||||
"definitions",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Keywords that carry no validation meaning — metadata a schema may hold
|
||||
* alongside its constraints. A schema built *only* from these accepts every
|
||||
* value, which is what the `untyped-schema` rule reports.
|
||||
* Keywords that actually constrain the instance being validated — the
|
||||
* assertion and applicator vocabularies, plus reference keywords.
|
||||
*
|
||||
* Defining the rule this way, rather than as "has no `type`", is deliberate:
|
||||
* `{"properties": {…}}` with no `type` does constrain its input, so calling it
|
||||
* unconstrained would be false. That shape is left alone; the rule fires on
|
||||
* `{}` and on annotation-only schemas — the object-literal spelling of the
|
||||
* same "any" that a bare `true` expresses.
|
||||
* This is an **allowlist**, and that direction is load-bearing. JSON Schema
|
||||
* ignores keywords it does not recognize, so a schema is unconstrained unless
|
||||
* something it carries is known to constrain: a denylist of known-annotation
|
||||
* keywords would let `{"vendorHint": true}` — which accepts every value —
|
||||
* pass as constrained simply because the keyword is unfamiliar.
|
||||
*
|
||||
* Two categories are deliberately absent. **Annotations** (`title`,
|
||||
* `description`, `default`, `examples`, `deprecated`, `readOnly`,
|
||||
* `writeOnly`, `$comment`) assert nothing. So do the **definition-only
|
||||
* containers** `$defs` / `definitions` and the identifier keywords `$id`,
|
||||
* `$schema`, `$anchor`, `$vocabulary`: they hold or name subschemas for
|
||||
* *other* schemas to reference, and constrain the current instance not at all.
|
||||
*
|
||||
* `format` is included even though 2020-12's default vocabulary treats it as
|
||||
* an annotation rather than an assertion. A schema declaring one is plainly an
|
||||
* attempt to constrain, and reporting it as accepting anything would be noise
|
||||
* rather than a finding an author can act on.
|
||||
*
|
||||
* Defining the rule this way, rather than as "has no `type`", is also
|
||||
* deliberate: `{"properties": {…}}` with no `type` does constrain its input,
|
||||
* so calling it unconstrained would be false. The rule fires on `{}`, on
|
||||
* annotation-only schemas, and on `$defs`-only schemas — the object-literal
|
||||
* spellings of the same "any" that a bare `true` expresses.
|
||||
*/
|
||||
const ANNOTATION_ONLY_KEYWORDS: ReadonlySet<string> = new Set([
|
||||
"title",
|
||||
"description",
|
||||
"default",
|
||||
"examples",
|
||||
"deprecated",
|
||||
"readOnly",
|
||||
"writeOnly",
|
||||
"$comment",
|
||||
"$id",
|
||||
"$schema",
|
||||
"$anchor",
|
||||
const CONSTRAINING_KEYWORDS: ReadonlySet<string> = new Set([
|
||||
// Assertions
|
||||
"type",
|
||||
"enum",
|
||||
"const",
|
||||
"multipleOf",
|
||||
"maximum",
|
||||
"exclusiveMaximum",
|
||||
"minimum",
|
||||
"exclusiveMinimum",
|
||||
"maxLength",
|
||||
"minLength",
|
||||
"pattern",
|
||||
"format",
|
||||
"maxItems",
|
||||
"minItems",
|
||||
"uniqueItems",
|
||||
"maxContains",
|
||||
"minContains",
|
||||
"maxProperties",
|
||||
"minProperties",
|
||||
"required",
|
||||
"dependentRequired",
|
||||
// Applicators
|
||||
"allOf",
|
||||
"anyOf",
|
||||
"oneOf",
|
||||
"not",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
"items",
|
||||
"prefixItems",
|
||||
"contains",
|
||||
"additionalItems",
|
||||
"unevaluatedItems",
|
||||
"properties",
|
||||
"patternProperties",
|
||||
"additionalProperties",
|
||||
"unevaluatedProperties",
|
||||
"propertyNames",
|
||||
"dependentSchemas",
|
||||
"dependencies",
|
||||
// References
|
||||
"$ref",
|
||||
"$dynamicRef",
|
||||
"$recursiveRef",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
@ -263,18 +323,26 @@ function lintNode(node: SchemaRecord, path: string, ctx: WalkContext): void {
|
|||
const type = node.type;
|
||||
|
||||
if (Array.isArray(type)) {
|
||||
const nonNull = type.filter(
|
||||
(t): t is string => typeof t === "string" && t !== "null",
|
||||
);
|
||||
const members = type.filter((t): t is string => typeof t === "string");
|
||||
// The suggestion is always `anyOf`, never "drop it from `required`".
|
||||
// Accepting an explicit JSON `null` and permitting the property to be
|
||||
// absent are independent: on a required `["null","boolean"]` field,
|
||||
// un-requiring it would both stop accepting `null` and start accepting
|
||||
// omission — a different contract, not the same one spelled portably.
|
||||
// `anyOf` branches each carrying a single `type` are equivalent, and this
|
||||
// lint treats them as portable.
|
||||
const branches = members.length > 0 ? members : type.map(() => "…");
|
||||
add(
|
||||
ctx,
|
||||
"type-union",
|
||||
"warning",
|
||||
path,
|
||||
`\`type\` is an array (${JSON.stringify(type)}). 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.`,
|
||||
nonNull.length === 1
|
||||
? `Use \`"type": "${nonNull[0]}"\` and leave the property out of \`required\` to express "may be absent".`
|
||||
: "Split the alternatives into `anyOf` branches, each with a single `type`.",
|
||||
`Split it into \`anyOf\` branches, each with a single \`type\` — \`{"anyOf": [${branches
|
||||
.map((t) => `{"type": "${t}"}`)
|
||||
.join(
|
||||
", ",
|
||||
)}]}\`. (Making the property optional instead is a different contract: absent is not the same as \`null\`.)`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -290,8 +358,8 @@ function lintNode(node: SchemaRecord, path: string, ctx: WalkContext): void {
|
|||
);
|
||||
}
|
||||
|
||||
const constrains = Object.keys(node).some(
|
||||
(key) => !ANNOTATION_ONLY_KEYWORDS.has(key),
|
||||
const constrains = Object.keys(node).some((key) =>
|
||||
CONSTRAINING_KEYWORDS.has(key),
|
||||
);
|
||||
if (!constrains) {
|
||||
add(
|
||||
|
|
@ -306,9 +374,18 @@ function lintNode(node: SchemaRecord, path: string, ctx: WalkContext): void {
|
|||
}
|
||||
|
||||
/**
|
||||
* Lint one of a tool's schemas. The root gets one extra rule: MCP requires a
|
||||
* tool schema to describe an object, and a root of any other type is refused
|
||||
* by the SDK-side parsers rather than merely degraded.
|
||||
* Lint one of a tool's schemas.
|
||||
*
|
||||
* `inputSchema` gets one extra rule at the root: MCP requires it to describe
|
||||
* an object, and the SDK enforces that — its `Tool` schema types `inputSchema`
|
||||
* with `type: literal("object")`, so a non-object root is refused by the
|
||||
* parser rather than merely degraded.
|
||||
*
|
||||
* That rule is **scoped to `inputSchema` deliberately**. The same SDK types
|
||||
* `outputSchema` as a plain loose object with no `type` constraint at all, so
|
||||
* a tool whose output is `{"type": "string"}` is conforming; flagging it would
|
||||
* report an error MCP does not require and would fail `--strict` on a valid
|
||||
* server.
|
||||
*/
|
||||
function lintSchema(
|
||||
schema: unknown,
|
||||
|
|
@ -316,7 +393,7 @@ function lintSchema(
|
|||
findings: SchemaFinding[],
|
||||
): void {
|
||||
const ctx: WalkContext = { schema: kind, findings };
|
||||
if (isRecord(schema)) {
|
||||
if (kind === "inputSchema" && isRecord(schema)) {
|
||||
const type = schema.type;
|
||||
if (typeof type === "string" && type !== "object") {
|
||||
add(
|
||||
|
|
@ -324,7 +401,7 @@ function lintSchema(
|
|||
"non-object-root",
|
||||
"error",
|
||||
"",
|
||||
`Root \`type\` is "${type}". MCP requires a tool's ${kind} to describe an object.`,
|
||||
`Root \`type\` is "${type}". MCP requires a tool's inputSchema to describe an object.`,
|
||||
'Wrap the value in an object schema — `{"type": "object", "properties": {…}}`.',
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue