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=].
+
+
+
+ | Keyword | Allowed form | Notes
+ |
+ | `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` | array | JSON 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 integer | only 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=]s | only 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` | any | allowed but ignored for validation
+ |
| `description` | [=string=] | allowed but ignored for validation
+ |
+
+The following keywords are explicitly excluded from the [=WebMCP input schema=] subset:
+
+
+ | Keyword | Rationale
+ |
+ | `$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 keyword | not 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.
+
+
+
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
1. Let |tool definition| be |targetToolMap|[|toolName|].
- 1. If [=tool is exposed to an origin=] given |targetOrigin|, |tool definition|'s [=tool
- definition/exposed origins=], and |callerOrigin| returns false, then [=queue a global task=]
- on the [=webmcp task source=] given |callerDocument|'s [=relevant global object=] to
- [=reject=] |promise| with an "{{UnknownError}}" {{DOMException}}, and abort these steps.
+ 1. If [=tool is exposed to an origin=] given |targetOrigin|, |tool definition|'s [=tool
+ definition/exposed origins=], and |callerOrigin| returns false, then [=queue a global task=]
+ on the [=webmcp task source=] given |callerDocument|'s [=relevant global object=] to
+ [=reject=] |promise| with an "{{UnknownError}}" {{DOMException}}, and abort these steps.
- Issue: Support more granular errors than "{{UnknownError}}", based on each failure case.
+ Issue: Support more granular errors than "{{UnknownError}}", based on each failure case.
+
+ 1. Let |schemaString| be |tool definition|'s [=tool definition/input schema=].
+
+ 1. If |schemaString| is not the empty string, then:
+
+ 1. Let |schemaObject| be the result of [=parse a JSON string to a JavaScript value=] given |schemaString| and |targetDocument|'s [=relevant realm=].
+
+ [=Assert=]: This will not throw, because |schemaString| was produced by [=serializing a JavaScript value to a JSON string=] during {{ModelContext/registerTool()}} and meta-validated as a [=valid WebMCP input schema=].
+
+ 1. Let |inputValue| be the result of [=parse a JSON string to a JavaScript value=] given |inputArguments| and |targetDocument|'s [=relevant realm=].
+
+ [=Assert=]: This will not throw, because |inputArguments| was produced by [=serializing a JavaScript value to a JSON string=] given |inputObject| above.
+
+ Note: Validation is performed on the parsed |inputArguments| — the value the tool's `execute` callback will actually receive — so that `JSON.stringify` transformations (e.g. `Infinity` → `null`, `undefined` stripped) are covered.
+
+ 1. Let |validationResult| be the result of [=validate a JavaScript value against a WebMCP input schema=] given |inputValue|, |schemaObject|, `""`, and `""`.
+
+ 1. If |validationResult| is not null, then:
+
+ 1. Let |errorMessage| be the result of [=serializing a JavaScript value to a JSON string=] given |validationResult|.
+
+ [=Assert=]: This will not throw.
+
+ 1. [=Queue a global task=] on the [=webmcp task source=] given |callerDocument|'s [=relevant global object=] to [=reject=] |promise| with a new "{{DataError}}" {{DOMException}} whose message is |errorMessage|.
+
+ 1. Abort these steps.
+
+ Note: The tool's `execute` callback is not invoked on validation failure, and no [=pending tool execution=] entry is created. This also protects against the unregistration → re-registration race noted in the [=tool execute steps=]: validation uses the current [=tool definition/input schema=] from |targetToolMap|[|toolName|], so stale `inputArguments` for an old schema correctly fail with `DataError` for the agent to re-`getTools()` and retry.
- 1. Let |completionSteps| be an algorithm that takes a [=string=]-or-null |result| and a
+ 1. Let |completionSteps| be an algorithm that takes a [=string=]-or-null |result| and a
[=boolean=] |success|, and runs the following steps:
1. [=Assert=]: these steps are running [=in parallel=].