ucp-schema
CLI and library for Universal Commerce Protocol (UCP) schemas.
UCP defines an open extensibility protocol on top of JSON Schema. Agents negotiate capabilities at runtime via self-describing payloads, extensions compose dynamically via allOf, and ucp_request/ucp_response annotations control field visibility per direction and operation. This tool implements UCP's composition and resolution pipeline: compose capability schemas, resolve annotations into standard JSON Schema, and validate payloads.
How It Works
The CLI exposes a progressive pipeline. Each command runs it up to its named step:
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
│ compose │ ──▶ │ resolve │ ──▶ │ validate │
└───────────────────┘ └───────────────────┘ └───────────────────┘
merge capability apply annotations check payload
schemas into one for direction + op against schema
Like gcc -E (preprocess only) vs gcc (full build), each command runs the pipeline to a different depth: compose stops after merging capability schemas, resolve applies annotations, and validate runs through to payload checking. When the input is a self-describing payload, earlier stages run automatically. lint is independent static analysis.
For example, a field annotated with "ucp_request": {"create": "omit", "update": "required"} disappears from create schemas but becomes required on update — one source schema, different views per operation. See Visibility Rules for the full worked example.
"I want to..."
| Goal | Command |
|---|---|
| Inspect the composed schema (annotations preserved) | compose payload.json --schema-local-base ./schemas --pretty |
| Get JSON Schema for an operation | resolve payload.json --op read --schema-local-base ./schemas |
| Resolve a single schema file (no composition) | resolve schema.json --request --op create |
| Validate a payload end-to-end | validate payload.json --op read --schema-local-base ./schemas |
| Check schemas for errors before runtime | lint schemas/ |
| Debug what the pipeline is doing | Add --verbose to any command |
Installation
# Install from crates.io
# Or build from source
CLI Reference
compose — Compose schemas from capabilities
Pure composition: merges capability schemas from a self-describing payload into one schema. Output preserves UCP annotations (no resolve step).
)
compose does not accept --request/--response/--op — those belong to resolve and validate.
# Inspect the merged schema before resolution
# Save for debugging
resolve — Generate operation-specific schema
Accepts a schema file or a self-describing payload. When given a payload, automatically composes schemas from capabilities before resolving.
# Schema input (direction required)
|
# Payload input (direction auto-inferred)
)
; )
)
)
# Schema file → resolved schema
# Self-describing payload → auto-compose, auto-detect direction, resolve
# Bundle external $refs into a self-contained schema
# Resolve from URL
validate — Validate payload against resolved schema
|)
|)
)
)
The validator auto-detects how to find the schema based on what flags you provide and what metadata the payload contains (see Validation Modes in Concepts):
| Pattern | Command | Schema Source | Direction |
|---|---|---|---|
| Response (self-describing) | validate response.json --op read |
ucp.capabilities URLs |
Auto |
| JSONRPC request | validate envelope.json --op create |
meta.profile URL |
Auto |
| REST request | validate payload.json --profile profile.json --op create |
--profile URL |
Request |
| Explicit schema | validate payload.json --schema s.json --request --op create |
--schema |
Specified |
# Self-describing response
# Explicit schema
# Machine-readable output for CI
# → {"valid":true}
# → {"valid":false,"errors":[{"path":"","message":"..."}]}
Exit codes: 0 valid, 1 validation failed, 2 schema error, 3 file/network error.
lint — Static analysis of schema files
Catch schema errors before runtime.
|)
| Category | Issue | Severity |
|---|---|---|
| Syntax | Invalid JSON | Error |
| References | $ref to missing file |
Error |
| References | $ref to missing anchor (#/$defs/foo) |
Error |
| Annotations | Invalid ucp_* type (must be string or object) |
Error |
| Annotations | Invalid visibility value (must be omit/required/optional) | Error |
| Hygiene | Missing $id field |
Warning |
| Hygiene | Unknown operation in annotation (e.g., {"delete": "omit"}) |
Warning |
# Lint a directory of schemas
# CI-friendly: fail on warnings, JSON output
Exit codes: 0 passed, 1 errors found, 2 path not found.
Concepts
Visibility Rules
ucp_request and ucp_response annotations control which fields appear in the resolved schema. Given a schema where id is server-generated:
Resolving for --request --op create removes id — clients don't send server-generated fields:
Resolving for --request --op update makes id required — you must specify which resource to update:
Annotations are stripped; output is standard JSON Schema.
Resolution rules:
| Value | Effect on Properties | Effect on Required Array |
|---|---|---|
"omit" |
Field removed | Field removed |
"required" |
Field kept | Field added |
"optional" |
Field kept | Field removed |
| (no annotation) | Field kept | Unchanged |
{ "transition": { "from", "to", "description" } } (schema transition) |
Matches from value |
Matches from value |
Annotations can be shorthand (all operations) or per-operation, and request/response are independent:
Valid operations: create, read, update, complete.
Schema transitions
Use a schema-transition object to signal a field contract will change, with a human-readable reason:
fromandtomust be one of:"omit","optional","required", and must be distinct (same value for both is invalid).descriptionis required and should explain the change and what to do instead.
During the transition period the resolved schema uses the from value as the field's visibility, so previous implementers are not immediately affected. The resolver emits the schema-transition context into the output schema:
x-ucp-schema-transition:{ "from", "to", "description" }on the property for tooling and docs.deprecated: true on the property only when the field is being removed (tois"omit").
Example: Removing a required field
// Phase 1: Field is required
// Phase 2: Schema transition (field stays required in resolved schema; tooling offers warnings)
// Phase 3: Remove
Shorthand schema transition (same transition for all operations):
Schema Composition
UCP payloads are self-describing — they embed ucp.capabilities metadata declaring which schemas apply. This lets multiple capability schemas compose into one:
How composition works:
- Root capability — one capability has no
extends, providing the base schema - Extensions — capabilities with
extendsadd fields to the root - Merge — extensions define their additions in
$defs[root_capability_name]; the tool composes them viaallOf
Graph rules: exactly one root capability (no extends), all extends targets must exist in capabilities, all extensions must transitively reach the root.
Schema authoring for extensions:
Extension schemas define their additions in $defs keyed by the root capability name:
Validation Modes
The validator supports four patterns for discovering which schema to validate against.
Response (self-describing) — The payload's ucp.capabilities declares schema URLs. Direction is auto-detected as response:
JSONRPC request — The envelope has meta.profile at root, with the payload nested under the capability short name (e.g., checkout). The validator fetches the profile, extracts capabilities, extracts the nested payload, then composes and validates:
REST request (--profile) — The profile URL comes via flag (equivalent to an HTTP header in production). The payload is the raw object, not wrapped in an envelope:
The --profile flag implies --request direction.
Explicit schema — Bypass self-describing metadata entirely. Requires explicit --request or --response:
Local Resolution
When working offline or testing schema changes, --schema-local-base maps schema URL paths to local files:
# Schema URL: https://ucp.dev/schemas/shopping/checkout.json
# Path extracted: /schemas/shopping/checkout.json
# Local file: ./local/schemas/shopping/checkout.json
When schema URLs have a prefix that doesn't match your local directory layout, --schema-remote-base strips it:
# URL: https://ucp.dev/draft/schemas/shopping/checkout.json
# Strip: https://ucp.dev/draft
# Local: ./site/schemas/shopping/checkout.json
Bundling
Schemas often use $ref to reference external files. The --bundle flag inlines all external references into a self-contained schema:
Bundling applies to schema file input only. When resolving payloads, composition already handles fetching and merging external schemas.
How it works:
- File refs (
"$ref": "types/buyer.json") are loaded and inlined - Fragment refs (
"$ref": "types/common.json#/$defs/address") navigate to the target definition - Internal refs in external files (
"$ref": "#/$defs/foo") resolve against their source file - Self-referential types (
"$ref": "#") are preserved (can't be inlined) - Circular references are detected and reported as errors
Strict Mode
By default, validation allows unknown fields — payloads may contain fields from capabilities the validator hasn't seen, and forward compatibility requires tolerating them. For closed systems or catching typos, --strict injects additionalProperties: false into all object schemas:
Warning: Strict mode conflicts with allOf composition. Each allOf branch validates independently and rejects properties from other branches. Use default (non-strict) mode for composed schemas.
Debugging with --verbose
All commands accept --verbose (or -v) to print pipeline stages to stderr:
)
Verbose output goes to stderr; JSON output on stdout is unaffected.
More Information
See FAQ.md for common questions about validator behavior, design decisions, and edge cases.
License
Apache-2.0