From 4ddaf46d4874c9e2d555f6adccc980f8d18044ef Mon Sep 17 00:00:00 2001 From: Eros483 Date: Thu, 3 Sep 2026 15:23:43 +0530 Subject: [PATCH] Input validation (Fixes #92) Codify WebMCP JSON Schema subset (Draft 2020-12) per 2026-03-05 CG resolution. Meta-validation in registerTool() (TypeError on invalid schema) and input validation in executeTool() parallel block before tool execute steps (DataError with {path,reason,schemaPath} for agent self-correction). Spec-only per review; WPT split to web-platform-tests/wpt (tentative). Coordinates with #254 (outputSchema, same dictionaries). --- index.bs | 229 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 215 insertions(+), 14 deletions(-) diff --git a/index.bs b/index.bs index 241bb2c..88269ac 100644 --- a/index.bs +++ b/index.bs @@ -213,6 +213,171 @@ An annotations is a [=struct=] with the following [=struct/items=]: :: a [=boolean=], initially false. +

JSON Schema subset and validation

+ +This specification uses the subset of [[!JSON-SCHEMA]] Draft 2020-12 defined below, per the 2026-03-05 CG resolution that the [=user agent=] is responsible for validation on all three layers (schema meta, input, output). Only input validation is normatively required by this section; schema meta-validation and input validation are specified below. Output validation will be defined in coordination with the `outputSchema` feature (PR #254). + +
+

The subset is intentionally small to keep the [=user agent=] payload minimal. MCP dropped hard `zod` → Standard Schema for the same reason.

+
+ +A WebMCP input schema is a JavaScript value that is a [=valid WebMCP input schema=]. + + + + +
KeywordAllowed formNotes +
`type``"null"`, `"boolean"`, `"object"`, `"array"`, `"number"`, `"integer"`, `"string"`, or an array of those`"integer"` means a number that is an integer +
`properties`object mapping [=string=] → [=valid WebMCP input schema=]only when instance is an object +
`required`array of [=strings=]only when instance is an object +
`enum`arrayJSON deep equality (SameValue for numbers except `0` and `-0` are equal) +
`minimum` / `maximum`[=number=]only when instance is a number +
`exclusiveMinimum` / `exclusiveMaximum`[=number=]only when instance is a number; Draft 2020-12 numeric form (boolean form is invalid) +
`minLength` / `maxLength`non-negative integeronly when instance is a string; measured in UTF-16 code units (JS `length`) +
`pattern`[=string=]only when instance is a string; ECMA-262 `RegExp` without flags, `test()` semantics (unanchored); invalid pattern makes the schema invalid +
`items`[=valid WebMCP input schema=]only when instance is an array; schema for elements beyond `prefixItems` +
`prefixItems`array of [=valid WebMCP input schema=]sonly when instance is an array; positional schemas for `0..n-1` +
`additionalProperties``false` only (or absent)if `false`, instance object MUST NOT contain properties not listed in `properties` +
`default`anyallowed but ignored for validation +
`description`[=string=]allowed but ignored for validation +
+ +The following keywords are explicitly excluded from the [=WebMCP input schema=] subset: + + + +
KeywordRationale +
`$ref`, `$defs`, `$anchor`, `$id`remote/DAG resolution and unbounded payload — keep UA small +
`unevaluatedProperties`, `unevaluatedItems`requires full schema graph evaluation, complex to implement +
`format`custom validators are not interoperable across UAs +
`contentEncoding`, `contentMediaType`, `contentSchema`binary/media handling is out of scope for tool inputs +
`dependentRequired`, `dependentSchemas`, `if`/`then`/`else`, `allOf`/`anyOf`/`oneOf`/`not`combinators/PR graph beyond trivial subset — defer until proven need +
any other keywordnot in the allowlist above — schema is invalid to avoid silent ignoring (e.g. `format: "email"` would otherwise appear enforced) +
+ +
+To determine if a JavaScript value |schema| is a valid WebMCP input schema, run these steps: + + 1. If |schema| is not an [=Object=], then return false. + 1. If |schema| is an array, then return false. + 1. Let |allowed| be the [=list=] `« "type", "properties", "required", "enum", "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "minLength", "maxLength", "pattern", "items", "prefixItems", "additionalProperties", "default", "description" »`. + 1. [=map/For each=] |key| → value of |schema|: + 1. If |allowed| does not [=list/contain=] |key|, then return false. + 1. If |schema|["type"] [=map/exists=]: + 1. Let |t| be |schema|["type"]. + 1. If |t| is a [=string=]: + 1. If |t| is not one of `"null"`, `"boolean"`, `"object"`, `"array"`, `"number"`, `"integer"`, `"string"`, then return false. + 1. Otherwise, if |t| is an array: + 1. If |t| [=list/is empty=], then return false. + 1. [=list/For each=] |el| of |t|: if |el| is not one of the seven strings above, then return false. + 1. Otherwise, return false. + 1. If |schema|["properties"] [=map/exists=]: + 1. Let |props| be |schema|["properties"]. + 1. If |props| is not an [=Object=] or is an array or is null, then return false. + 1. [=map/For each=] |k| → |v| of |props|: if |v| is not a [=valid WebMCP input schema=], then return false. + 1. If |schema|["required"] [=map/exists=]: + 1. If |schema|["required"] is not an array, then return false. + 1. [=list/For each=] |el| of |schema|["required"]: if |el| is not a [=string=], then return false. + 1. If |schema|["enum"] [=map/exists=]: if |schema|["enum"] is not an array, then return false. + 1. If |schema|["minimum"] [=map/exists=] or |schema|["maximum"] [=map/exists=] or |schema|["exclusiveMinimum"] [=map/exists=] or |schema|["exclusiveMaximum"] [=map/exists=]: + 1. [=list/For each=] |k| of `« "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum" »`: if |schema|[|k|] [=map/exists=] and |schema|[|k|] is not a [=number=], then return false. + 1. If |schema|["minLength"] [=map/exists=] or |schema|["maxLength"] [=map/exists=]: + 1. [=list/For each=] |k| of `« "minLength", "maxLength" »`: if |schema|[|k|] [=map/exists=] and (|schema|[|k|] is not a [=number=] or |schema|[|k|] is not an integer or |schema|[|k|] < 0), then return false. + 1. If |schema|["pattern"] [=map/exists=]: + 1. If |schema|["pattern"] is not a [=string=], then return false. + 1. If parsing |schema|["pattern"] as an ECMA-262 {{RegExp}} pattern with no flags throws, then return false. + 1. If |schema|["prefixItems"] [=map/exists=]: + 1. If |schema|["prefixItems"] is not an array, then return false. + 1. [=list/For each=] |el| of |schema|["prefixItems"]: if |el| is not a [=valid WebMCP input schema=], then return false. + 1. If |schema|["items"] [=map/exists=]: if |schema|["items"] is not a [=valid WebMCP input schema=], then return false. + 1. If |schema|["additionalProperties"] [=map/exists=]: if |schema|["additionalProperties"] is not `false`, then return false. + 1. If |schema|["description"] [=map/exists=]: if |schema|["description"] is not a [=string=], then return false. + 1. Return true. +
+ +
+To validate a JavaScript value against a WebMCP input schema given a JavaScript value |instance| and a [=valid WebMCP input schema=] |schema|, a [=string=] |path| (a JSON Pointer, initially `""`), and a [=string=] |schemaPath| (initially `""`), run these steps. It returns null on success, or a [=map=] `«[ "path" → |path|, "reason" → [=string=], "schemaPath" → |schemaPath| ]»` on the first failure, depth-first: + + 1. Let |type| be |schema|["type"] if [=map/exists=]; otherwise null. + + Note: `type` can be a [=string=] or an array of strings. + + 1. If |type| is not null: + 1. Let |types| be `« |type| »` if |type| is a [=string=]; otherwise |type|. + 1. Let |matched| be false. + 1. [=list/For each=] |t| of |types|: + 1. If |t| is `"null"` and |instance| is null, then set |matched| to true. + 1. If |t| is `"boolean"` and |instance| is a [=boolean=], then set |matched| to true. + 1. If |t| is `"string"` and |instance| is a [=string=], then set |matched| to true. + 1. If |t| is `"number"` and |instance| is a [=number=], then set |matched| to true. + 1. If |t| is `"integer"` and |instance| is a [=number=] and |instance| is an integer, then set |matched| to true. + 1. If |t| is `"array"` and |instance| is an array, then set |matched| to true. + 1. If |t| is `"object"` and |instance| is an [=Object=] and |instance| is not an array and |instance| is not null, then set |matched| to true. + 1. If |matched| is false, then return `«[ "path" → |path|, "reason" → "type", "schemaPath" → |schemaPath| + "/type" ]»`. + + 1. If |schema|["enum"] [=map/exists=]: + 1. Let |found| be false. + 1. [=list/For each=] |cand| of |schema|["enum"]: + 1. If |instance| and |cand| are JSON-deep-equal (numbers `0` and `-0` equal, `NaN` never equal), then set |found| to true. + 1. If |found| is false, then return `«[ "path" → |path|, "reason" → "enum", "schemaPath" → |schemaPath| + "/enum" ]»`. + + 1. If |instance| is a [=number=]: + + 1. If |schema|["minimum"] [=map/exists=] and |instance| < |schema|["minimum"], then return `«[ "path" → |path|, "reason" → "minimum", "schemaPath" → |schemaPath| + "/minimum" ]»`. + 1. If |schema|["maximum"] [=map/exists=] and |instance| > |schema|["maximum"], then return `«[ "path" → |path|, "reason" → "maximum", "schemaPath" → |schemaPath| + "/maximum" ]»`. + 1. If |schema|["exclusiveMinimum"] [=map/exists=] and |instance| ≤ |schema|["exclusiveMinimum"], then return `«[ "path" → |path|, "reason" → "exclusiveMinimum", "schemaPath" → |schemaPath| + "/exclusiveMinimum" ]»`. + 1. If |schema|["exclusiveMaximum"] [=map/exists=] and |instance| ≥ |schema|["exclusiveMaximum"], then return `«[ "path" → |path|, "reason" → "exclusiveMaximum", "schemaPath" → |schemaPath| + "/exclusiveMaximum" ]»`. + + 1. If |instance| is a [=string=]: + + 1. If |schema|["minLength"] [=map/exists=] and |instance|'s [=string/length=] < |schema|["minLength"], then return `«[ "path" → |path|, "reason" → "minLength", "schemaPath" → |schemaPath| + "/minLength" ]»`. + 1. If |schema|["maxLength"] [=map/exists=] and |instance|'s [=string/length=] > |schema|["maxLength"], then return `«[ "path" → |path|, "reason" → "maxLength", "schemaPath" → |schemaPath| + "/maxLength" ]»`. + 1. If |schema|["pattern"] [=map/exists=]: + 1. Let |re| be the ECMA-262 {{RegExp}} for |schema|["pattern"] with no flags. + 1. If |re| test of |instance| is false, then return `«[ "path" → |path|, "reason" → "pattern", "schemaPath" → |schemaPath| + "/pattern" ]»`. + + 1. If |instance| is an [=Object=] and |instance| is not an array and |instance| is not null: + + 1. If |schema|["required"] [=map/exists=]: + 1. [=list/For each=] |req| of |schema|["required"]: + 1. If |instance|[|req|] does not [=map/exist=], then return `«[ "path" → |path|, "reason" → "required", "schemaPath" → |schemaPath| + "/required" ]»` with |path| + "/" + escaped |req|. + + Note: The failing |path| is |path| plus `"/"` plus |req| escaped for JSON Pointer (`~` → `~0`, `/` → `~1`). + + 1. If |schema|["properties"] [=map/exists=]: + 1. [=map/For each=] |prop| → |subSchema| of |schema|["properties"]: + 1. If |instance|[|prop|] [=map/exists=]: + 1. Let |subPath| be |path| + "/" + escaped |prop|, and |subSchemaPath| be |schemaPath| + "/properties/" + escaped |prop|. + 1. Let |r| be the result of [=validate a JavaScript value against a WebMCP input schema=] given |instance|[|prop|], |subSchema|, |subPath|, and |subSchemaPath|. + 1. If |r| is not null, then return |r|. + + 1. If |schema|["additionalProperties"] is `false` and |schema|["properties"] [=map/exists=]: + 1. [=map/For each=] |key| → v of |instance|: + 1. If |schema|["properties"][|key|] does not [=map/exist=], then return `«[ "path" → |path| + "/" + escaped |key|, "reason" → "additionalProperties", "schemaPath" → |schemaPath| + "/additionalProperties" ]»`. + + 1. If |instance| is an array: + + 1. If |schema|["prefixItems"] [=map/exists=]: + 1. Let |prefix| be |schema|["prefixItems"]. + 1. [=list/For each=] |i| in `0 .. |prefix| [=list/size=] - 1`: + 1. If |i| < |instance| [=list/size=]: + 1. Let |r| be the result of [=validate a JavaScript value against a WebMCP input schema=] given |instance|[|i|], |prefix|[|i|], |path| + "/" + |i|, and |schemaPath| + "/prefixItems/" + |i|. + 1. If |r| is not null, then return |r|. + 1. If |schema|["items"] [=map/exists=]: + 1. [=list/For each=] |j| in `|prefix| [=list/size=] .. |instance| [=list/size=] - 1`: + 1. Let |r| be the result of [=validate a JavaScript value against a WebMCP input schema=] given |instance|[|j|], |schema|["items"], |path| + "/" + |j|, and |schemaPath| + "/items". + 1. If |r| is not null, then return |r|. + 1. Otherwise, if |schema|["items"] [=map/exists=]: + 1. [=list/For each=] |i| in `0 .. |instance| [=list/size=] - 1`: + 1. Let |r| be the result of [=validate a JavaScript value against a WebMCP input schema=] given |instance|[|i|], |schema|["items"], |path| + "/" + |i|, and |schemaPath| + "/items". + 1. If |r| is not null, then return |r|. + + 1. Return null. + + Note: JSON Pointer escaping is `~` → `~0`, `/` → `~1` per RFC 6901. |path| `""` denotes the root. + +
+

Pending tool executions

A pending tool execution is a [=struct=] with the following [=struct/items=]: @@ -690,16 +855,24 @@ The registerTool(tool, optionsinputSchema: { toJSON() {return HTMLDivElement;}}", or "inputSchema: { toJSON() {return undefined;}}".

-
  • Re-throws exceptions thrown by "JSON.stringify()", e.g., when - "inputSchema" is an object with a circular reference, etc.

  • - - +
  • Re-throws exceptions thrown by "JSON.stringify()", e.g., when + "inputSchema" is an object with a circular reference, etc.

  • + + + + 1. If |stringified input schema| is not the empty string, then: + + 1. Let |parsed schema| be the result of [=parse a JSON string to a JavaScript value=] given |stringified input schema| and [=this=]'s [=relevant realm=]. -1. If |options|'s {{ModelContextRegisterToolOptions/signal}} [=map/exists=] and is - [=AbortSignal/aborted=], then return [=a promise rejected with=] |options|'s - {{ModelContextRegisterToolOptions/signal}}'s [=AbortSignal/abort reason=]. + [=Assert=]: This will not throw, because |stringified input schema| was produced by [=serializing a JavaScript value to a JSON string=] above. -1. Let |exposed origins| be an empty [=list=] of [=origins=]. + 1. If |parsed schema| is not a [=valid WebMCP input schema=], then return [=a promise rejected with=] a new {{TypeError}} whose message describes that `inputSchema` contains an unsupported keyword or invalid structure for the [=WebMCP input schema=] subset. + + 1. If |options|'s {{ModelContextRegisterToolOptions/signal}} [=map/exists=] and is + [=AbortSignal/aborted=], then return [=a promise rejected with=] |options|'s + {{ModelContextRegisterToolOptions/signal}}'s [=AbortSignal/abort reason=]. + + 1. Let |exposed origins| be an empty [=list=] of [=origins=]. 1. If |options|'s {{ModelContextRegisterToolOptions/exposedTo}} [=map/exists=], then: @@ -983,14 +1156,42 @@ The executeTool(tool, inputObject