Optional JSV schema validation
This package provides Snodo.Schema.Validator.JSV, an opt-in implementation of
the core validator boundary using JSV. It adds no runtime dependencies to the
snodo core and is not an MCP protocol extension. Basic remains a useful,
explicitly partial dependency-free option; installing this package does not
silently change any server's validation policy.
Use from an application
Add snodo_jsv next to snodo:
{:snodo_jsv, "~> 0.1.0"}
Then select the backend on the server:
defmodule MyApp.MCPServer do
use Snodo.Server,
name: "my-application",
version: "0.1.0",
protocols: [Snodo.Protocol.V2026_07_28],
schema_validator: Snodo.Schema.Validator.JSV
tool(MyApp.Search)
end
Snodo.Router.dispatch/5 also accepts schema_validator: Snodo.Schema.Validator.JSV.
Input schemas and output schemas remain the original JSON-decoded maps. The
backend validates data but never inserts defaults, converts keys to atoms,
returns cast values, or rewrites the tool definitions advertised by the server.
Policy and scope
- The default dialect is JSON Schema Draft 2020-12. Explicit
http://json-schema.org/draft-07/schema(with an optional trailing#) is supported too. Custom and mixed dialects fail admission. This is schema dialect support, not support for an older MCP protocol version. - Give
$idan absolute form with an authority, such ashttps://example.test/schemas/thing. JSV resolves each$refagainst the nearest$idusingURI.merge/2, which before Elixir 1.19 rejected a base without an authority. Aurn:identifier therefore builds on 1.19 and later and fails on 1.18 with "you must merge onto an absolute URI". This package supports 1.18, so the restriction is real rather than theoretical. - The adapter validates schemas against JSV's bundled meta-schemas before
building a root. Schema failures and unsupported policy features become
Snodo.Schema.Validator.JSV.BuildError;validate/2raises that error so the router treats it as a server configuration failure. Invalid instances return{:error, reason}and become ordinary invalid-params/output-validation errors at the existing router boundary. No library error details are intentionally added to public invalid-params messages. - Local
$ref,$defs,$anchor,$dynamicRef,$dynamicAnchor, and bundled resources identified by$idwork without fetching. Only the library's built-in meta-schemas may be externally resolved. Unresolved HTTP(S), file, relative, andjsv:module:references fail closed: no network or file fetch, no invocation of an application module'sjson_schema/0. There is no configurable resolver in this slice. Application$idvalues may not use thejson-schema.orgauthority, which is reserved for trusted bundled dialects; otherwise a local resource could shadow a meta-schema and disable validation. - References may target only schema-valued positions defined by the selected
supported dialect, or trusted bundled meta-schemas. Pointers into annotations,
defaults, enum/example data, or maps that contain named schemas are rejected
before building, even if that data could independently resemble a schema.
Anchors and resource identifiers must also occur at admitted schema positions;
duplicate identifiers cannot ambiguously select a target. This intentionally
stricter policy prevents a reference from activating data that bypassed the
root meta-schema check or from invoking a hidden casting build hook. Put
reusable schemas in
$defs(2020-12) ordefinitions(Draft 7), not annotation data. JSON Pointer escapes and resource-relative references still work. - JSON Schema booleans work as nested schemas and in standalone
compile/1. MCP tool registration still requires schema maps. JSV's separate remote resolver boolean restriction therefore does not arise in this adapter. - Standard vocabularies come from the selected bundled dialect. Unknown
required vocabularies fail admission; unknown optional vocabularies and
ordinary vendor annotation keywords do not introduce custom validation.
A
$vocabularykey in an ordinary schema does not install a custom dialect. - JSV casting keywords (
jsv-cast,x-jsv-cast) are rejected before building, including their build-time hooks. Runtime casting and atom-producing casts are also disabled. The safety scan is deliberately conservative: it rejects casting, mixed/custom$schema, and unsupported required$vocabularydeclarations even when nested in annotation, enum, or example data. Property names inside schema containers are not mistaken for those keywords. This restriction avoids hidden schema-control instructions reached by references; it is not a claim to accept every possible JSON Schema document. - The normal 2020-12 dialect treats
formatas annotation. A precompiled root may explicitly requestformats: :assertionusing JSV's built-in validators, orformats: :annotationto explicitly disable assertions. Unknown format names fail compilation in assertion mode. Content vocabulary fields remain annotations; validation does not fetch URLs or decode arbitrary content. - Schemas and instances must already be JSON values with string keys, not structs or general atoms. Convert domain values deliberately before reaching the boundary. This package does not broaden JavaScript/JSON numeric precision or promise identical regular-expression behavior across runtimes.
The backend brings the broader 2020-12 validation vocabulary (composition, conditionals, references, evaluated-property/item tracking, and more) through a bounded application policy. It is not an unqualified whole-protocol conformance claim. Elicitation's protocol-specific restricted form schema remains a separate boundary.
Compiling a fixed catalog once
The default validate/2 compiles each supplied schema on each call. This is a
correctness-first path with no global state or unbounded cache. For a fixed
catalog, an application can retain compiled roots in an ordinary module:
defmodule MyApp.SchemaValidator do
@behaviour Snodo.Schema.Validator
alias Snodo.Schema.Validator.JSV, as: Backend
@schema MyApp.Search.input_schema()
@compiled case Backend.compile(@schema, formats: :assertion) do
{:ok, compiled} -> compiled
{:error, error} -> raise error
end
@impl true
def validate(instance, schema) when schema === @schema,
do: Backend.validate_compiled(instance, @compiled)
def validate(instance, schema), do: Backend.validate(instance, schema)
end
Use schema_validator: MyApp.SchemaValidator on the server. Compile all input
and output schemas that need the same policy; the fallback above deliberately
uses the default policy. compile/1 and compile/2 return
{:ok, compiled} | {:error, BuildError.t()}. Compiled values are opaque immutable
roots; do not fabricate or modify them. Recompile them when schemas or backend
versions change. A catalog cache, if later needed, must have application-owned
bounds and a lifecycle.
Validation of attacker-controlled data is still work: put input-size, execution, and concurrency limits at the transport/application boundary. This package has no independent validation timeout and is not an untrusted-schema sandbox.
Version choice and alternatives
The package allows jsv ~> 0.22. The checked-in lock resolves 0.25.0, and the
tests pass on both 0.22.0 and 0.25.0.
JSV supplies runtime compilation and documents 2020-12/Draft 7 support,
vocabularies, and per-call casting controls. Its own tests pin the JSON Schema
test suite; this package's tests verify the adapter policy and router behavior,
not a new independent rerun of that entire upstream suite.
JSONSchex was a credible alternative, but its documented unresolved custom meta-schema fallback calls for additional fail-closed admission. Exonerate's compile-time code generation and documented dialect limitations are less suited to this dynamic boundary. ExJsonSchema's documented Draft 4/6/7 support does not meet the 2020-12 requirement. These are reasons for this integration choice, not claims that the alternatives cannot suit other applications.
Primary references checked on 2026-09-14:
- JSV package
- JSV API and casting options
- JSV resolver contract
- Pinned JSV dependency/test-suite declarations
- JSONSchex dialect policy
- Exonerate documented limitations
- ExJsonSchema supported drafts
Verification
From this package directory:
ERL_FLAGS='+S 4:4' mix deps.get
ERL_FLAGS='+S 4:4' mix quality
ERL_FLAGS='+S 4:4' mix quality.types
mix quality checks formatting, compilation warnings, strict Credo, and tests.
The tests cover extended keywords, offline references, recursive/dynamic refs,
boolean schemas, explicit Draft 7, malformed schema admission, format policy,
casting/module-hook rejection, exact handler arguments and advertised schemas,
and correct input/output/configuration error routing. Core tests and official
client/schema evidence run in their separate lanes.