Commit 78367f8

mo khan <mo@mokhan.ca>
2026-08-03 18:28:31
refactor: tidy up the schema validation and output reporting
cli
1 parent 18581b9
lib/scim/kit/cli/schemas/list_response.schema.json
@@ -8,6 +8,5 @@
     "startIndex": { "type": "integer" },
     "Resources": { "type": "array", "items": {} }
   },
-  "required": ["schemas", "totalResults"],
-  "additionalProperties": false
+  "required": ["schemas", "totalResults"]
 }
lib/scim/kit/cli/schemas/resource_types.schema.json
@@ -25,8 +25,7 @@
                 "schema": { "type": "string" },
                 "required": { "type": "boolean" }
               },
-              "required": ["schema", "required"],
-              "additionalProperties": false
+              "required": ["schema", "required"]
             }
           },
           "externalId": { "type": "string" },
@@ -38,16 +37,12 @@
               "lastModified": { "type": "string", "format": "date-time" },
               "location": { "type": "string" },
               "version": { "type": "string" }
-            },
-            "required": [],
-            "additionalProperties": false
+            }
           }
         },
-        "required": ["schemas", "name", "endpoint", "schema"],
-        "additionalProperties": false
+        "required": ["schemas", "name", "endpoint", "schema"]
       }
     }
   },
-  "required": ["schemas", "totalResults", "Resources"],
-  "additionalProperties": false
+  "required": ["schemas", "totalResults", "Resources"]
 }
lib/scim/kit/cli/schemas/schemas.schema.json
@@ -22,13 +22,11 @@
           "externalId": { "type": "string" },
           "meta": { "$ref": "#/$defs/meta" }
         },
-        "required": ["schemas", "id", "attributes"],
-        "additionalProperties": false
+        "required": ["schemas", "id", "attributes"]
       }
     }
   },
   "required": ["schemas", "totalResults", "Resources"],
-  "additionalProperties": false,
   "$defs": {
     "attribute": {
       "type": "object",
@@ -64,8 +62,7 @@
           "items": { "$ref": "#/$defs/attribute" }
         }
       },
-      "required": ["name", "type"],
-      "additionalProperties": false
+      "required": ["name", "type"]
     },
     "meta": {
       "type": "object",
@@ -75,9 +72,7 @@
         "lastModified": { "type": "string", "format": "date-time" },
         "location": { "type": "string" },
         "version": { "type": "string" }
-      },
-      "required": [],
-      "additionalProperties": false
+      }
     }
   }
 }
lib/scim/kit/cli/schemas/service_provider_config.schema.json
@@ -9,8 +9,7 @@
     "patch": {
       "type": "object",
       "properties": { "supported": { "type": "boolean" } },
-      "required": ["supported"],
-      "additionalProperties": false
+      "required": ["supported"]
     },
     "bulk": {
       "type": "object",
@@ -19,8 +18,7 @@
         "maxOperations": { "type": "integer" },
         "maxPayloadSize": { "type": "integer" }
       },
-      "required": ["supported", "maxOperations", "maxPayloadSize"],
-      "additionalProperties": false
+      "required": ["supported", "maxOperations", "maxPayloadSize"]
     },
     "filter": {
       "type": "object",
@@ -28,26 +26,22 @@
         "supported": { "type": "boolean" },
         "maxResults": { "type": "integer" }
       },
-      "required": ["supported", "maxResults"],
-      "additionalProperties": false
+      "required": ["supported", "maxResults"]
     },
     "changePassword": {
       "type": "object",
       "properties": { "supported": { "type": "boolean" } },
-      "required": ["supported"],
-      "additionalProperties": false
+      "required": ["supported"]
     },
     "sort": {
       "type": "object",
       "properties": { "supported": { "type": "boolean" } },
-      "required": ["supported"],
-      "additionalProperties": false
+      "required": ["supported"]
     },
     "etag": {
       "type": "object",
       "properties": { "supported": { "type": "boolean" } },
-      "required": ["supported"],
-      "additionalProperties": false
+      "required": ["supported"]
     },
     "authenticationSchemes": {
       "type": "array",
@@ -61,8 +55,7 @@
           "documentationUri": { "type": "string" },
           "primary": { "type": "boolean" }
         },
-        "required": ["type", "name", "description"],
-        "additionalProperties": false
+        "required": ["type", "name", "description"]
       }
     },
     "meta": {
@@ -73,14 +66,11 @@
         "lastModified": { "type": "string", "format": "date-time" },
         "location": { "type": "string" },
         "version": { "type": "string" }
-      },
-      "required": [],
-      "additionalProperties": false
+      }
     }
   },
   "required": [
     "schemas", "patch", "bulk", "filter", "changePassword", "sort", "etag",
     "authenticationSchemes"
-  ],
-  "additionalProperties": false
+  ]
 }
lib/scim/kit/cli/app.rb
@@ -20,7 +20,7 @@ module Scim
 
         desc 'discover', "Discover a server's ServiceProviderConfig, Schemas, and ResourceTypes"
         def discover
-          Reporting.rescue_errors(shell) { fetch_discovery }
+          run { fetch_discovery }
         end
 
         desc 'list RESOURCE_TYPE', 'List resources of a given type'
@@ -31,22 +31,30 @@ module Scim
         method_option :sort_order, desc: 'ascending or descending'
         method_option :attributes, desc: 'Comma-separated attribute names to return'
         def list(resource_type)
-          Reporting.rescue_errors(shell) { fetch_list(resource_type) }
+          run { fetch_list(resource_type) }
         end
 
         desc 'get RESOURCE_TYPE ID', 'Fetch a single resource'
         method_option :attributes, desc: 'Comma-separated attribute names to return'
         def get(resource_type, id)
-          Reporting.rescue_errors(shell) { fetch_resource(resource_type, id) }
+          run { fetch_resource(resource_type, id) }
         end
 
         private
 
+        def run
+          exit(yield)
+        rescue RequestFailed => error
+          exit(reporter.failure(error.result.body))
+        rescue Error => error
+          exit(reporter.failure(detail: error.message))
+        end
+
         def fetch_discovery
           responses = {}
           RESOURCES.each do |key, path|
-            result = http.fetch(Cli.join_uri(url, path), headers: headers)
-            return Reporting.report(result, shell) unless result.ok?
+            result = client.fetch(path)
+            return reporter.report(result) unless result.ok?
 
             responses[key] = result.body
           end
@@ -54,13 +62,9 @@ module Scim
         end
 
         def report_discovery(combined)
-          if options[:validate]
-            Reporting.report_with_validation(
-              combined, shell, discovery_errors(combined.body)
-            )
-          else
-            Reporting.report(combined, shell)
-          end
+          return reporter.report(combined) unless options[:validate]
+
+          reporter.report_validation(combined, discovery_errors(combined.body))
         end
 
         def discovery_errors(responses)
@@ -72,21 +76,19 @@ module Scim
         end
 
         def fetch_list(resource_type)
-          uri = Cli.join_uri(url, resolve_endpoint(resource_type))
-          query = list_query_string
-          uri.query = query unless query.empty?
-          result = http.fetch(uri, headers: headers)
+          endpoint = resolve_endpoint(resource_type)
+          result = client.fetch(endpoint, query: list_query)
           validate_and_report(result, resource_type) do |schema|
             SchemaRegistry.list_response_with_items(schema)
           end
         end
 
         def fetch_resource(resource_type, id)
-          uri = Cli.join_uri(url, "#{resolve_endpoint(resource_type)}/#{id}")
-          if options[:attributes]
-            uri.query = URI.encode_www_form(attributes: options[:attributes])
-          end
-          result = http.fetch(uri, headers: headers)
+          endpoint = resolve_endpoint(resource_type)
+          path = "#{endpoint}/#{URI.encode_uri_component(id)}"
+          result = client.fetch(
+            path, query: { 'attributes' => options[:attributes] }
+          )
           validate_and_report(result, resource_type)
         end
 
@@ -98,52 +100,76 @@ module Scim
         end
 
         def resource_type_entry(resource_type)
-          @resource_type_entry ||=
-            resource_type_resolver.resource_type_for(resource_type)
+          resource_type_resolver.resource_type_for(resource_type)
         end
 
         def resource_type_resolver
-          @resource_type_resolver ||= ResourceTypeResolver.new(
-            http, url, headers: headers
-          )
+          @resource_type_resolver ||= ResourceTypeResolver.new(client)
         end
 
         def resource_schema_resolver
-          @resource_schema_resolver ||= ResourceSchemaResolver.new(
-            http, url, headers: headers
-          )
+          @resource_schema_resolver ||= ResourceSchemaResolver.new(client)
         end
 
-        def validate_and_report(result, resource_type)
-          unless result.ok? && options[:validate]
-            return Reporting.report(result, shell)
-          end
+        def validate_and_report(result, resource_type, &transform)
+          return reporter.report(result) unless result.ok? && options[:validate]
 
+          schema = schema_for(resource_type)
+          return reporter.report(result) unless schema
+
+          errors = Validator.errors_for(
+            prepare(schema, &transform), result.body
+          )
+          reporter.report_validation(result, errors)
+        end
+
+        def schema_for(resource_type)
           entry = resource_type_entry(resource_type)
           schema = resource_schema_resolver.schema_for(entry)
-          unless schema
-            Reporting.warn_unresolvable_schema(resource_type, shell)
-            return Reporting.report(result, shell)
+          if schema
+            warn_undeclared_extensions
+          else
+            warn_unresolvable_schema(resource_type)
           end
+          schema
+        end
 
-          schema = yield(schema) if block_given?
-          errors = Validator.errors_for(schema, result.body)
-          Reporting.report_with_validation(result, shell, errors)
-        end
-
-        def list_query_string
-          URI.encode_www_form(
-            {
-              'filter' => options[:filter],
-              'startIndex' => options[:start_index],
-              'count' => options[:count],
-              'sortBy' => options[:sort_by],
-              'sortOrder' => options[:sort_order],
-              'attributes' => options[:attributes]
-            }.compact
+        def prepare(schema)
+          schema = SparseSchema.relax(schema) if options[:attributes]
+          block_given? ? yield(schema) : schema
+        end
+
+        def warn_unresolvable_schema(resource_type)
+          reporter.warn(
+            "no schema found for resource type #{resource_type.inspect}; " \
+            'skipping validation'
           )
         end
 
+        def warn_undeclared_extensions
+          resource_schema_resolver.undeclared_extensions.each do |urn|
+            reporter.warn(
+              "schema extension #{urn.inspect} is declared by the resource " \
+              'type but missing from /Schemas'
+            )
+          end
+        end
+
+        def reporter
+          @reporter ||= Reporter.new(shell)
+        end
+
+        def list_query
+          {
+            'filter' => options[:filter],
+            'startIndex' => options[:start_index],
+            'count' => options[:count],
+            'sortBy' => options[:sort_by],
+            'sortOrder' => options[:sort_order],
+            'attributes' => options[:attributes]
+          }
+        end
+
         def url
           @url ||= begin
             resolved = options[:url] || ENV.fetch('SCIM_KIT_URL', nil)
@@ -157,15 +183,17 @@ module Scim
           @headers ||= options[:header].each_with_object({}) do |header, hash|
             name, value = header.split(':', 2)
             if value.nil?
-              raise Thor::Error, "malformed --header #{header.inspect} (expected \"Name: Value\")"
+              raise Thor::Error,
+                "malformed --header #{header.inspect} " \
+                '(expected "Name: Value")'
             end
 
             hash[name.to_s.strip] = value.to_s.strip
           end
         end
 
-        def http
-          @http ||= Scim::Kit::Http.new
+        def client
+          @client ||= Client.new(url, headers: headers)
         end
       end
     end
lib/scim/kit/cli/client.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Scim
+  module Kit
+    module Cli
+      class Client
+        def initialize(base_url, headers: {}, http: Scim::Kit::Http.new)
+          @base_url = base_url
+          @headers = headers
+          @http = http
+        end
+
+        def fetch(path, query: {})
+          http.fetch(uri_for(path, query), headers: headers)
+        end
+
+        private
+
+        attr_reader :base_url, :headers, :http
+
+        def uri_for(path, query)
+          uri = URI.join("#{base_url.to_s.sub(%r{/+\z}, '')}/", path)
+          encoded = URI.encode_www_form(query.compact)
+          uri.query = encoded unless encoded.empty?
+          uri
+        end
+      end
+    end
+  end
+end
lib/scim/kit/cli/reporter.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Scim
+  module Kit
+    module Cli
+      class Reporter
+        SUCCESS = 0
+        FAILURE = 1
+
+        def initialize(shell)
+          @shell = shell
+        end
+
+        def report(result)
+          result.ok? ? success(result.body) : failure(result.body)
+        end
+
+        def report_validation(result, errors)
+          shell.say(pretty(result.body))
+          return SUCCESS if errors.empty?
+
+          failure(validation_errors: errors)
+        end
+
+        def success(body)
+          shell.say(pretty(body))
+          SUCCESS
+        end
+
+        def failure(body)
+          shell.say_error(pretty(body))
+          FAILURE
+        end
+
+        def warn(message)
+          shell.say_error("warning: #{message}")
+        end
+
+        private
+
+        attr_reader :shell
+
+        def pretty(body)
+          JSON.pretty_generate(body)
+        end
+      end
+    end
+  end
+end
lib/scim/kit/cli/reporting.rb
@@ -1,51 +0,0 @@
-# frozen_string_literal: true
-
-module Scim
-  module Kit
-    module Cli
-      module Reporting
-        def self.report(result, shell)
-          if result.ok?
-            shell.say(JSON.pretty_generate(result.body))
-            exit(0)
-          else
-            shell.say_error(JSON.pretty_generate(result.body))
-            exit(1)
-          end
-        end
-
-        def self.report_error(message, shell)
-          shell.say_error(JSON.pretty_generate(detail: message))
-          exit(1)
-        end
-
-        def self.report_with_validation(result, shell, errors)
-          shell.say(JSON.pretty_generate(result.body))
-          if errors.empty?
-            exit(0)
-          else
-            shell.say_error(
-              JSON.pretty_generate(validation_errors: errors)
-            )
-            exit(1)
-          end
-        end
-
-        def self.warn_unresolvable_schema(resource_type, shell)
-          shell.say_error(
-            'warning: no schema found for resource type ' \
-            "#{resource_type.inspect}; skipping validation"
-          )
-        end
-
-        def self.rescue_errors(shell)
-          yield
-        rescue RequestFailed => error
-          report(error.result, shell)
-        rescue UnknownResourceType, MissingEndpoint, InvalidResponse => error
-          report_error(error.message, shell)
-        end
-      end
-    end
-  end
-end
lib/scim/kit/cli/resource_schema_resolver.rb
@@ -21,15 +21,17 @@ module Scim
               'location' => { 'type' => 'string' },
               'version' => { 'type' => 'string' }
             },
-            'required' => [],
-            'additionalProperties' => false
+            'required' => ['resourceType']
           }
         }.freeze
 
-        def initialize(http, base_url, headers: {})
-          @http = http
-          @base_url = base_url
-          @headers = headers
+        COMMON_REQUIRED = %w[schemas id].freeze
+
+        attr_reader :undeclared_extensions
+
+        def initialize(client)
+          @client = client
+          @undeclared_extensions = []
         end
 
         def schema_for(resource_type)
@@ -42,7 +44,7 @@ module Scim
 
         private
 
-        attr_reader :http, :base_url, :headers
+        attr_reader :client
 
         def urns_for(resource_type)
           extensions = Array(resource_type[:schemaExtensions])
@@ -66,15 +68,17 @@ module Scim
           {
             'type' => 'object',
             'properties' => properties,
-            'required' => required,
-            'additionalProperties' => false
+            'required' => COMMON_REQUIRED | required
           }
         end
 
         def merge_extensions(resource_type, schemas, properties, required)
           Array(resource_type[:schemaExtensions]).each do |extension|
             extension_schema = schemas[extension[:schema]]
-            next unless extension_schema
+            unless extension_schema
+              undeclared_extensions << extension[:schema]
+              next
+            end
 
             properties[extension[:schema]] =
               ScimSchemaConverter.convert(extension_schema)
@@ -83,8 +87,7 @@ module Scim
         end
 
         def fetch_schemas(urns)
-          uri = Cli.join_uri(base_url, 'Schemas')
-          result = http.fetch(uri, headers: headers)
+          result = client.fetch('Schemas')
           schemas = result.ok? ? Cli.collection(result.body) : nil
           return {} if schemas.nil?
 
lib/scim/kit/cli/resource_type_resolver.rb
@@ -4,10 +4,8 @@ module Scim
   module Kit
     module Cli
       class ResourceTypeResolver
-        def initialize(http, base_url, headers: {})
-          @http = http
-          @base_url = base_url
-          @headers = headers
+        def initialize(client)
+          @client = client
         end
 
         def resource_type_for(name)
@@ -22,15 +20,18 @@ module Scim
 
         private
 
-        attr_reader :http, :base_url, :headers
+        attr_reader :client
 
         def matches?(type, name)
           type[:id]&.casecmp?(name) || type[:name]&.casecmp?(name)
         end
 
         def resource_types
-          uri = Cli.join_uri(base_url, 'ResourceTypes')
-          result = http.fetch(uri, headers: headers)
+          @resource_types ||= fetch_resource_types
+        end
+
+        def fetch_resource_types
+          result = client.fetch('ResourceTypes')
           raise RequestFailed, result unless result.ok?
 
           types = Cli.collection(result.body)
lib/scim/kit/cli/schema_registry.rb
@@ -5,6 +5,7 @@ module Scim
     module Cli
       module SchemaRegistry
         DIR = File.expand_path('schemas', __dir__)
+        LIST_RESPONSE = 'list_response.schema.json'
 
         FILES = {
           service_provider_configuration:
@@ -13,19 +14,30 @@ module Scim
           resource_types: 'resource_types.schema.json'
         }.freeze
 
-        def self.fetch(key)
-          @cache ||= {}
-          @cache[key] ||= load_schema(FILES.fetch(key))
-        end
+        class << self
+          def fetch(key)
+            load_schema(FILES.fetch(key))
+          end
 
-        def self.list_response_with_items(resource_schema)
-          schema = load_schema('list_response.schema.json')
-          schema['properties']['Resources']['items'] = resource_schema
-          schema
-        end
+          def list_response_with_items(resource_schema)
+            schema = load_schema(LIST_RESPONSE)
+            schema['properties']['Resources']['items'] = resource_schema
+            schema
+          end
+
+          private
+
+          def load_schema(file_name)
+            JSON.parse(sources[file_name] ||= read(file_name))
+          end
+
+          def read(file_name)
+            File.read(File.join(DIR, file_name))
+          end
 
-        def self.load_schema(file_name)
-          JSON.parse(File.read(File.join(DIR, file_name)))
+          def sources
+            @sources ||= {}
+          end
         end
       end
     end
lib/scim/kit/cli/scim_schema_converter.rb
@@ -14,48 +14,53 @@ module Scim
           'dateTime' => { 'type' => 'string', 'format' => 'date-time' }
         }.each_value(&:freeze).freeze
 
-        def self.convert(schema)
-          object_schema(schema[:attributes] || [])
-        end
+        class << self
+          def convert(schema)
+            object_schema(schema[:attributes] || [])
+          end
 
-        def self.object_schema(attributes)
-          {
-            'type' => 'object',
-            'properties' => properties_for(attributes),
-            'required' => required_for(attributes),
-            'additionalProperties' => false
-          }
-        end
+          private
 
-        def self.properties_for(attributes)
-          attributes.to_h { |a| [a[:name], attribute_schema(a)] }
-        end
+          def object_schema(attributes)
+            {
+              'type' => 'object',
+              'properties' => properties_for(attributes),
+              'required' => required_for(attributes)
+            }
+          end
 
-        def self.required_for(attributes)
-          attributes.filter_map { |a| a[:name] if a[:required] }
-        end
+          def properties_for(attributes)
+            attributes.to_h { |a| [a[:name], attribute_schema(a)] }
+          end
 
-        def self.attribute_schema(attribute)
-          schema = leaf_schema(attribute)
-          return schema unless attribute[:multiValued]
+          def required_for(attributes)
+            attributes.filter_map do |a|
+              a[:name] if a[:required] && a[:returned] != 'never'
+            end
+          end
 
-          { 'type' => 'array', 'items' => schema }
-        end
+          def attribute_schema(attribute)
+            schema = leaf_schema(attribute)
+            return schema unless attribute[:multiValued]
 
-        def self.leaf_schema(attribute)
-          if attribute[:type] == 'complex'
-            object_schema(attribute[:subAttributes] || [])
-          else
-            with_enum(attribute)
+            { 'type' => 'array', 'items' => schema }
           end
-        end
 
-        def self.with_enum(attribute)
-          schema = TYPE_MAP.fetch(attribute[:type])
-          canonical_values = attribute[:canonicalValues]
-          return schema if canonical_values.nil? || canonical_values.empty?
+          def leaf_schema(attribute)
+            if attribute[:type] == 'complex'
+              object_schema(attribute[:subAttributes] || [])
+            else
+              with_enum(attribute)
+            end
+          end
+
+          def with_enum(attribute)
+            schema = TYPE_MAP.fetch(attribute[:type]) { {} }
+            canonical_values = attribute[:canonicalValues]
+            return schema if canonical_values.nil? || canonical_values.empty?
 
-          schema.merge('enum' => canonical_values)
+            schema.merge('enum' => canonical_values)
+          end
         end
       end
     end
lib/scim/kit/cli/sparse_schema.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Scim
+  module Kit
+    module Cli
+      module SparseSchema
+        ALWAYS_RETURNED = %w[schemas id].freeze
+
+        class << self
+          def relax(schema)
+            strip_required(schema).merge('required' => ALWAYS_RETURNED)
+          end
+
+          private
+
+          def strip_required(value)
+            case value
+            when Hash
+              value.except('required')
+                .transform_values { |v| strip_required(v) }
+            when Array
+              value.map { |v| strip_required(v) }
+            else
+              value
+            end
+          end
+        end
+      end
+    end
+  end
+end
lib/scim/kit/cli/validator.rb
@@ -4,14 +4,18 @@ module Scim
   module Kit
     module Cli
       module Validator
-        def self.errors_for(schema, data)
-          JSONSchemer.schema(schema)
-            .validate(normalize(data))
-            .map { |error| JSONSchemer::Errors.pretty(error) }
-        end
+        class << self
+          def errors_for(schema, data)
+            JSONSchemer.schema(schema)
+              .validate(normalize(data))
+              .map { |error| JSONSchemer::Errors.pretty(error) }
+          end
+
+          private
 
-        def self.normalize(data)
-          JSON.parse(JSON.generate(data))
+          def normalize(data)
+            JSON.parse(JSON.generate(data))
+          end
         end
       end
     end
lib/scim/kit/cli.rb
@@ -4,11 +4,13 @@ require 'json_schemer'
 require 'thor'
 
 require 'scim/kit/cli/app'
-require 'scim/kit/cli/reporting'
+require 'scim/kit/cli/client'
+require 'scim/kit/cli/reporter'
 require 'scim/kit/cli/resource_schema_resolver'
 require 'scim/kit/cli/resource_type_resolver'
 require 'scim/kit/cli/schema_registry'
 require 'scim/kit/cli/scim_schema_converter'
+require 'scim/kit/cli/sparse_schema'
 require 'scim/kit/cli/validator'
 
 module Scim
@@ -39,10 +41,6 @@ module Scim
 
       class InvalidResponse < Error; end
 
-      def self.join_uri(base_url, path)
-        URI.join("#{base_url.to_s.sub(%r{/+\z}, '')}/", path)
-      end
-
       # SCIM collection endpoints (/Schemas, /ResourceTypes) return a
       # ListResponse per RFC 7644 section 4, but some servers return a
       # bare array. Return the underlying array for either shape, or nil.
spec/scim/kit/cli/app_spec.rb
@@ -317,7 +317,10 @@ RSpec.describe Scim::Kit::Cli::App do
         let(:list_response) do
           {
             schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
-            totalResults: 1, Resources: [{ id: '1', userName: 'bjensen' }]
+            totalResults: 1,
+            Resources: [
+              { schemas: [core_urn], id: '1', userName: 'bjensen' }
+            ]
           }
         end
 
@@ -334,6 +337,66 @@ RSpec.describe Scim::Kit::Cli::App do
         end
       end
 
+      context 'when --attributes narrows the response' do
+        before do
+          stub_request(:get, "#{base_url}/Users?attributes=id")
+            .to_return(
+              status: 200,
+              body: {
+                schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
+                totalResults: 1,
+                Resources: [{ schemas: [core_urn], id: '1' }]
+              }.to_json
+            )
+        end
+
+        it 'exits 0 without demanding attributes the server was not asked for' do
+          allow($stdout).to receive(:print)
+          instance = app('validate' => true, 'attributes' => 'id')
+
+          expect(exit_status { instance.list('User') }).to eq(0)
+        end
+      end
+
+      context 'when the resource type declares an extension /Schemas omits' do
+        let(:extension_urn) { 'urn:vendor:2.0:Thing' }
+        let(:resource_types) do
+          [{
+            id: 'User', name: 'User', endpoint: '/Users', schema: core_urn,
+            schemaExtensions: [{ schema: extension_urn, required: true }]
+          }]
+        end
+
+        before do
+          stub_request(:get, "#{base_url}/Users").to_return(
+            status: 200,
+            body: {
+              schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
+              totalResults: 1,
+              Resources: [
+                { schemas: [core_urn], id: '1', userName: 'bjensen' }
+              ]
+            }.to_json
+          )
+        end
+
+        it 'warns about the undeclared extension' do
+          allow($stdout).to receive(:print)
+          instance = app('validate' => true)
+
+          expect { exit_status { instance.list('User') } }
+            .to output(/#{Regexp.escape(extension_urn)}/).to_stderr
+        end
+
+        it 'still exits 0' do
+          allow($stdout).to receive(:print)
+          allow($stderr).to receive(:print)
+          instance = app('validate' => true)
+
+          expect(exit_status { instance.list('User') }).to eq(0)
+        end
+      end
+
       context 'when a resource in the list response is invalid' do
         let(:list_response) do
           {
@@ -421,6 +484,28 @@ RSpec.describe Scim::Kit::Cli::App do
       end
     end
 
+    context 'when the id needs escaping' do
+      it 'escapes a space rather than raising URI::InvalidURIError' do
+        stub = stub_request(:get, "#{base_url}/Users/mo%20khan")
+          .to_return(status: 200, body: {}.to_json)
+        allow($stdout).to receive(:print)
+
+        exit_status { app.get('User', 'mo khan') }
+
+        expect(stub).to have_been_requested
+      end
+
+      it 'escapes separators so an id cannot traverse the endpoint' do
+        stub = stub_request(:get, "#{base_url}/Users/..%2Fadmin%23x%3Fy")
+          .to_return(status: 200, body: {}.to_json)
+        allow($stdout).to receive(:print)
+
+        exit_status { app.get('User', '../admin#x?y') }
+
+        expect(stub).to have_been_requested
+      end
+    end
+
     context 'when the get request fails' do
       before do
         stub_request(:get, "#{base_url}/Users/123")
@@ -463,7 +548,10 @@ RSpec.describe Scim::Kit::Cli::App do
       context 'when the resource is valid' do
         before do
           stub_request(:get, "#{base_url}/Users/123").to_return(
-            status: 200, body: { id: '123', userName: 'bjensen' }.to_json
+            status: 200,
+            body: {
+              schemas: [core_urn], id: '123', userName: 'bjensen'
+            }.to_json
           )
         end
 
spec/scim/kit/cli/client_spec.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Cli::Client do
+  let(:body) { { id: '1' }.to_json }
+
+  def client(base_url, headers: {})
+    described_class.new(base_url, headers: headers)
+  end
+
+  describe '#fetch' do
+    it 'joins a base url without a trailing slash to a path' do
+      stub = stub_request(:get, 'https://example.com/scim/v2/Users')
+        .to_return(status: 200, body: body)
+
+      client('https://example.com/scim/v2').fetch('Users')
+
+      expect(stub).to have_been_requested
+    end
+
+    it 'joins a base url with a trailing slash to a path' do
+      stub = stub_request(:get, 'https://example.com/scim/v2/Users')
+        .to_return(status: 200, body: body)
+
+      client('https://example.com/scim/v2/').fetch('Users')
+
+      expect(stub).to have_been_requested
+    end
+
+    it 'joins a base url with multiple trailing slashes to a path' do
+      stub = stub_request(:get, 'https://example.com/scim/v2/Users')
+        .to_return(status: 200, body: body)
+
+      client('https://example.com/scim/v2///').fetch('Users')
+
+      expect(stub).to have_been_requested
+    end
+
+    it 'appends the query string' do
+      stub = stub_request(:get, 'https://example.com/Users?count=2&filter=x')
+        .to_return(status: 200, body: body)
+
+      client('https://example.com')
+        .fetch('Users', query: { 'filter' => 'x', 'count' => 2 })
+
+      expect(stub).to have_been_requested
+    end
+
+    it 'omits blank query values' do
+      stub = stub_request(:get, 'https://example.com/Users')
+        .to_return(status: 200, body: body)
+
+      client('https://example.com')
+        .fetch('Users', query: { 'filter' => nil, 'count' => nil })
+
+      expect(stub).to have_been_requested
+    end
+
+    it 'sends the configured headers' do
+      auth = { 'Authorization' => 'Bearer xyz' }
+      stub = stub_request(:get, 'https://example.com/Users')
+        .with(headers: auth).to_return(status: 200, body: body)
+
+      client('https://example.com', headers: auth).fetch('Users')
+
+      expect(stub).to have_been_requested
+    end
+
+    it 'returns the parsed result' do
+      stub_request(:get, 'https://example.com/Users')
+        .to_return(status: 200, body: body)
+
+      result = client('https://example.com').fetch('Users')
+
+      expect(result.body).to eql(id: '1')
+    end
+  end
+end
spec/scim/kit/cli/reporter_spec.rb
@@ -0,0 +1,112 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Cli::Reporter do
+  subject { described_class.new(Thor::Shell::Basic.new) }
+
+  let(:body) { { id: '123' } }
+  let(:pretty) { JSON.pretty_generate(body) }
+
+  describe '#report' do
+    context 'when the result is ok' do
+      let(:result) { Scim::Kit::Http::Result.new(200, body) }
+
+      it 'prints the body as pretty json to stdout' do
+        expect { subject.report(result) }.to output("#{pretty}\n").to_stdout
+      end
+
+      it 'returns a success status' do
+        allow($stdout).to receive(:print)
+
+        expect(subject.report(result)).to eq(0)
+      end
+    end
+
+    context 'when the result is not ok' do
+      let(:result) { Scim::Kit::Http::Result.new(404, body) }
+
+      it 'prints the body as pretty json to stderr' do
+        expect { subject.report(result) }.to output("#{pretty}\n").to_stderr
+      end
+
+      it 'returns a failure status' do
+        allow($stderr).to receive(:print)
+
+        expect(subject.report(result)).to eq(1)
+      end
+    end
+  end
+
+  describe '#report_validation' do
+    let(:result) { Scim::Kit::Http::Result.new(200, body) }
+
+    context 'without errors' do
+      it 'prints the body to stdout' do
+        expect { subject.report_validation(result, []) }
+          .to output("#{pretty}\n").to_stdout
+      end
+
+      it 'returns a success status' do
+        allow($stdout).to receive(:print)
+
+        expect(subject.report_validation(result, [])).to eq(0)
+      end
+    end
+
+    context 'with errors' do
+      let(:errors) { ['root is missing required keys: id'] }
+
+      it 'prints the errors to stderr' do
+        allow($stdout).to receive(:print)
+
+        expect { subject.report_validation(result, errors) }
+          .to output(/validation_errors/).to_stderr
+      end
+
+      it 'still prints the body to stdout' do
+        allow($stderr).to receive(:print)
+
+        expect { subject.report_validation(result, errors) }
+          .to output(/#{pretty}/).to_stdout
+      end
+
+      it 'returns a failure status' do
+        allow($stdout).to receive(:print)
+        allow($stderr).to receive(:print)
+
+        expect(subject.report_validation(result, errors)).to eq(1)
+      end
+    end
+  end
+
+  describe '#failure' do
+    it 'prints the body as pretty json to stderr' do
+      expect { subject.failure(detail: 'boom') }
+        .to output("#{JSON.pretty_generate(detail: 'boom')}\n").to_stderr
+    end
+
+    it 'returns a failure status' do
+      allow($stderr).to receive(:print)
+
+      expect(subject.failure(detail: 'boom')).to eq(1)
+    end
+  end
+
+  describe '#success' do
+    it 'prints the body as pretty json to stdout' do
+      expect { subject.success(body) }.to output("#{pretty}\n").to_stdout
+    end
+
+    it 'returns a success status' do
+      allow($stdout).to receive(:print)
+
+      expect(subject.success(body)).to eq(0)
+    end
+  end
+
+  describe '#warn' do
+    it 'prefixes the message and writes it to stderr' do
+      expect { subject.warn('watch out') }
+        .to output("warning: watch out\n").to_stderr
+    end
+  end
+end
spec/scim/kit/cli/reporting_spec.rb
@@ -1,157 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.describe Scim::Kit::Cli::Reporting do
-  let(:shell) { Thor::Shell::Basic.new }
-
-  describe '.report' do
-    context 'when the result is ok' do
-      let(:result) { Scim::Kit::Http::Result.new(200, { id: '123' }) }
-
-      it 'prints the body as pretty json to stdout' do
-        expect { exit_status { described_class.report(result, shell) } }
-          .to output("#{JSON.pretty_generate(id: '123')}\n").to_stdout
-      end
-
-      it 'exits 0' do
-        allow($stdout).to receive(:print)
-
-        expect(exit_status { described_class.report(result, shell) }).to eq(0)
-      end
-    end
-
-    context 'when the result is not ok' do
-      let(:result) { Scim::Kit::Http::Result.new(404, { detail: 'not found' }) }
-
-      it 'prints the body as pretty json to stderr' do
-        expect { exit_status { described_class.report(result, shell) } }
-          .to output("#{JSON.pretty_generate(detail: 'not found')}\n").to_stderr
-      end
-
-      it 'exits 1' do
-        allow($stderr).to receive(:print)
-
-        expect(exit_status { described_class.report(result, shell) }).to eq(1)
-      end
-    end
-  end
-
-  describe '.report_error' do
-    it 'prints a synthesized detail message as pretty json to stderr' do
-      expect { exit_status { described_class.report_error('boom', shell) } }
-        .to output("#{JSON.pretty_generate(detail: 'boom')}\n").to_stderr
-    end
-
-    it 'exits 1' do
-      allow($stderr).to receive(:print)
-
-      expect(exit_status { described_class.report_error('boom', shell) }).to eq(1)
-    end
-  end
-
-  describe '.report_with_validation' do
-    let(:result) { Scim::Kit::Http::Result.new(200, { id: '123' }) }
-
-    context 'when there are no errors' do
-      it 'prints the body as pretty json to stdout' do
-        expect do
-          exit_status do
-            described_class.report_with_validation(result, shell, [])
-          end
-        end.to output("#{JSON.pretty_generate(id: '123')}\n").to_stdout
-      end
-
-      it 'exits 0' do
-        allow($stdout).to receive(:print)
-
-        status = exit_status do
-          described_class.report_with_validation(result, shell, [])
-        end
-
-        expect(status).to eq(0)
-      end
-    end
-
-    context 'when there are errors' do
-      let(:errors) { ['root is missing required keys: userName'] }
-
-      it 'prints the body to stdout and the errors to stderr' do
-        allow($stdout).to receive(:print)
-
-        expect { exit_status { described_class.report_with_validation(result, shell, errors) } }
-          .to output("#{JSON.pretty_generate(validation_errors: errors)}\n").to_stderr
-      end
-
-      it 'exits 1' do
-        allow($stdout).to receive(:print)
-        allow($stderr).to receive(:print)
-
-        status = exit_status { described_class.report_with_validation(result, shell, errors) }
-
-        expect(status).to eq(1)
-      end
-    end
-  end
-
-  describe '.warn_unresolvable_schema' do
-    it 'prints a warning mentioning the resource type to stderr' do
-      expect { described_class.warn_unresolvable_schema('Foo', shell) }
-        .to output(/no schema found for resource type "Foo"/).to_stderr
-    end
-  end
-
-  describe '.rescue_errors' do
-    it 'returns the value of the block when nothing is raised' do
-      expect(described_class.rescue_errors(shell) { 'ok' }).to eq('ok')
-    end
-
-    context 'when the block raises Cli::RequestFailed' do
-      let(:result) { Scim::Kit::Http::Result.new(500, { detail: 'boom' }) }
-      let(:block) do
-        -> { described_class.rescue_errors(shell) { raise Scim::Kit::Cli::RequestFailed, result } }
-      end
-
-      it 'reports the failed result' do
-        expect { exit_status(&block) }
-          .to output("#{JSON.pretty_generate(detail: 'boom')}\n").to_stderr
-      end
-    end
-
-    context 'when the block raises Cli::UnknownResourceType' do
-      let(:block) do
-        lambda do
-          described_class.rescue_errors(shell) do
-            raise Scim::Kit::Cli::UnknownResourceType.new('Nope', ['User'])
-          end
-        end
-      end
-
-      it 'reports the error message' do
-        expect { exit_status(&block) }.to output(/Nope/).to_stderr
-      end
-    end
-
-    context 'when the block raises Cli::MissingEndpoint' do
-      let(:block) do
-        lambda do
-          described_class.rescue_errors(shell) { raise Scim::Kit::Cli::MissingEndpoint, 'User' }
-        end
-      end
-
-      it 'reports the error message' do
-        expect { exit_status(&block) }.to output(/User/).to_stderr
-      end
-    end
-
-    context 'when the block raises Cli::InvalidResponse' do
-      let(:block) do
-        lambda do
-          described_class.rescue_errors(shell) { raise Scim::Kit::Cli::InvalidResponse, 'bad shape' }
-        end
-      end
-
-      it 'reports the error message' do
-        expect { exit_status(&block) }.to output(/bad shape/).to_stderr
-      end
-    end
-  end
-end
spec/scim/kit/cli/resource_schema_resolver_spec.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 RSpec.describe Scim::Kit::Cli::ResourceSchemaResolver do
-  subject { described_class.new(Scim::Kit::Http.new, base_url, headers: {}) }
+  subject { described_class.new(Scim::Kit::Cli::Client.new(base_url)) }
 
   let(:base_url) { FFaker::Internet.uri('https') }
   let(:core_urn) { 'urn:ietf:params:scim:schemas:core:2.0:User' }
@@ -31,14 +31,25 @@ RSpec.describe Scim::Kit::Cli::ResourceSchemaResolver do
   end
 
   describe '#schema_for' do
+    let(:schema) { subject.schema_for(resource_type) }
+    let(:resource) do
+      {
+        schemas: [core_urn], id: '1', userName: 'mo',
+        meta: { resourceType: 'User' },
+        extension_urn => { employeeNumber: '1' }
+      }
+    end
+
+    def errors_for(body)
+      Scim::Kit::Cli::Validator.errors_for(schema, body)
+    end
+
     context 'when the core and extension schemas are both found' do
-      let(:schema) { subject.schema_for(resource_type) }
       let(:expected_extension_schema) do
         {
           'type' => 'object',
           'properties' => { 'employeeNumber' => { 'type' => 'string' } },
-          'required' => [],
-          'additionalProperties' => false
+          'required' => []
         }
       end
 
@@ -68,8 +79,42 @@ RSpec.describe Scim::Kit::Cli::ResourceSchemaResolver do
         expect(schema['required']).to include(extension_urn)
       end
 
-      it 'disallows undeclared top-level properties' do
-        expect(schema['additionalProperties']).to be(false)
+      it 'allows vendor properties the server adds' do
+        errors = errors_for(resource.merge('urn:vendor:custom' => { a: true }))
+
+        expect(errors).to be_empty
+      end
+
+      it 'requires the attributes RFC 7643 section 3.1 mandates' do
+        expect(errors_for(userName: 'mo'))
+          .to include(/missing required keys.*schemas/)
+      end
+
+      it 'requires id' do
+        expect(errors_for(userName: 'mo'))
+          .to include(/missing required keys.*id/)
+      end
+
+      it 'requires meta.resourceType when meta is returned' do
+        expect(errors_for(resource.merge(meta: {})))
+          .to include(/meta.*missing required keys.*resourceType/)
+      end
+    end
+
+    context 'when the resource type declares an extension /Schemas omits' do
+      before do
+        stub_request(:get, "#{base_url}/Schemas")
+          .to_return(status: 200, body: [core_schema].to_json)
+      end
+
+      it 'records the undeclared extension URN' do
+        subject.schema_for(resource_type)
+
+        expect(subject.undeclared_extensions).to eql([extension_urn])
+      end
+
+      it 'still accepts a resource carrying that extension' do
+        expect(errors_for(resource)).to be_empty
       end
     end
 
spec/scim/kit/cli/resource_type_resolver_spec.rb
@@ -1,7 +1,11 @@
 # frozen_string_literal: true
 
 RSpec.describe Scim::Kit::Cli::ResourceTypeResolver do
-  subject { described_class.new(Scim::Kit::Http.new, base_url, headers: headers) }
+  subject do
+    described_class.new(
+      Scim::Kit::Cli::Client.new(base_url, headers: headers)
+    )
+  end
 
   let(:base_url) { FFaker::Internet.uri('https') }
   let(:headers) { {} }
@@ -29,6 +33,19 @@ RSpec.describe Scim::Kit::Cli::ResourceTypeResolver do
       expect(subject.resource_type_for('user')).to include(endpoint: '/Users')
     end
 
+    it 'resolves each name independently' do
+      subject.resource_type_for('User')
+
+      expect(subject.resource_type_for('Group')).to include(endpoint: '/Groups')
+    end
+
+    it 'fetches ResourceTypes once across repeated lookups' do
+      subject.resource_type_for('User')
+      subject.resource_type_for('Group')
+
+      expect(a_request(:get, "#{base_url}/ResourceTypes")).to have_been_made.once
+    end
+
     it 'raises when no resource type matches the given name' do
       expect { subject.resource_type_for('Nope') }.to raise_error(
         Scim::Kit::Cli::UnknownResourceType, /Nope/
spec/scim/kit/cli/scim_schema_converter_spec.rb
@@ -8,8 +8,7 @@ RSpec.describe Scim::Kit::Cli::ScimSchemaConverter do
         {
           'type' => 'object',
           'properties' => { 'userName' => { 'type' => 'string' } },
-          'required' => [],
-          'additionalProperties' => false
+          'required' => []
         }
       end
 
@@ -89,8 +88,7 @@ RSpec.describe Scim::Kit::Cli::ScimSchemaConverter do
             'givenName' => { 'type' => 'string' },
             'familyName' => { 'type' => 'string' }
           },
-          'required' => ['givenName'],
-          'additionalProperties' => false
+          'required' => ['givenName']
         }
       end
 
@@ -128,5 +126,35 @@ RSpec.describe Scim::Kit::Cli::ScimSchemaConverter do
 
       expect(result['properties']).to eql({})
     end
+
+    context 'with an attribute type the spec does not define' do
+      it 'leaves an unrecognized type unconstrained instead of raising' do
+        schema = { attributes: [{ name: 'x', type: 'Reference' }] }
+
+        expect(described_class.convert(schema)['properties']['x']).to eql({})
+      end
+
+      it 'leaves a missing type unconstrained instead of raising' do
+        schema = { attributes: [{ name: 'x' }] }
+
+        expect(described_class.convert(schema)['properties']['x']).to eql({})
+      end
+    end
+
+    context 'with a required attribute the server never returns' do
+      let(:schema) do
+        {
+          attributes: [
+            { name: 'password', type: 'string', required: true,
+              returned: 'never' },
+            { name: 'userName', type: 'string', required: true }
+          ]
+        }
+      end
+
+      it 'excludes it from required' do
+        expect(described_class.convert(schema)['required']).to eql(['userName'])
+      end
+    end
   end
 end
spec/scim/kit/cli/sparse_schema_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Cli::SparseSchema do
+  describe '.relax' do
+    let(:schema) do
+      {
+        'type' => 'object',
+        'properties' => {
+          'schemas' => { 'type' => 'array', 'items' => { 'type' => 'string' } },
+          'id' => { 'type' => 'string' },
+          'userName' => { 'type' => 'string' },
+          'meta' => {
+            'type' => 'object',
+            'properties' => { 'resourceType' => { 'type' => 'string' } },
+            'required' => ['resourceType']
+          },
+          'emails' => {
+            'type' => 'array',
+            'items' => {
+              'type' => 'object',
+              'properties' => { 'value' => { 'type' => 'string' } },
+              'required' => ['value']
+            }
+          }
+        },
+        'required' => %w[schemas id userName]
+      }
+    end
+
+    it 'requires only the always-returned attributes' do
+      expect(described_class.relax(schema)['required']).to eql(%w[schemas id])
+    end
+
+    it 'strips required from nested objects' do
+      relaxed = described_class.relax(schema)
+
+      expect(relaxed['properties']['meta']).not_to include('required')
+    end
+
+    it 'strips required from array items' do
+      relaxed = described_class.relax(schema)
+
+      expect(relaxed['properties']['emails']['items'])
+        .not_to include('required')
+    end
+
+    it 'leaves the original schema untouched' do
+      described_class.relax(schema)
+
+      expect(schema['required']).to eql(%w[schemas id userName])
+    end
+
+    it 'keeps type constraints' do
+      relaxed = described_class.relax(schema)
+
+      expect(relaxed['properties']['userName']).to eql('type' => 'string')
+    end
+
+    it 'accepts a resource carrying only the requested attributes' do
+      errors = Scim::Kit::Cli::Validator.errors_for(
+        described_class.relax(schema), schemas: ['urn:x'], id: '1'
+      )
+
+      expect(errors).to be_empty
+    end
+  end
+end
spec/scim/kit/cli_spec.rb
@@ -1,26 +1,6 @@
 # frozen_string_literal: true
 
 RSpec.describe Scim::Kit::Cli do
-  describe '.join_uri' do
-    it 'joins a base url without a trailing slash to a path' do
-      uri = described_class.join_uri('https://example.com/scim/v2', 'Users')
-
-      expect(uri.to_s).to eql('https://example.com/scim/v2/Users')
-    end
-
-    it 'joins a base url with a trailing slash to a path' do
-      uri = described_class.join_uri('https://example.com/scim/v2/', 'Users')
-
-      expect(uri.to_s).to eql('https://example.com/scim/v2/Users')
-    end
-
-    it 'joins a base url with multiple trailing slashes to a path' do
-      uri = described_class.join_uri('https://example.com/scim/v2///', 'Users')
-
-      expect(uri.to_s).to eql('https://example.com/scim/v2/Users')
-    end
-  end
-
   describe '.collection' do
     it 'returns a bare array unchanged' do
       expect(described_class.collection([{ id: '1' }])).to eql([{ id: '1' }])
.rubocop.yml
@@ -51,7 +51,6 @@ Lint/StructNewOverride:
 Metrics/AbcSize:
   Exclude:
     - 'lib/scim/kit/v2/service_provider_configuration.rb'
-    - 'lib/scim/kit/cli/app.rb'
 
 Metrics/BlockLength:
   Exclude:
@@ -63,10 +62,6 @@ Metrics/ClassLength:
   Exclude:
     - 'lib/scim/kit/cli/app.rb'
 
-Metrics/MethodLength:
-  Exclude:
-    - 'lib/scim/kit/cli/app.rb'
-
 Metrics/ModuleLength:
   Exclude:
     - 'spec/**/*.rb'
@@ -97,10 +92,6 @@ Style/IfUnlessModifier:
   Exclude:
     - 'lib/scim/kit/v2/attribute.rb'
 
-Style/StderrPuts:
-  Exclude:
-    - 'lib/scim/kit/cli/reporting.rb'
-
 Style/StringLiterals:
   EnforcedStyle: 'single_quotes'
 
CHANGELOG.md
@@ -14,6 +14,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   responses against a JSON Schema (built-in RFC 7643 schemas for
   `discover`; derived from the target server's own `/Schemas` for
   `list`/`get`).
+- Validate the attributes RFC 7643 section 3.1 requires of a returned
+  resource (`schemas`, `id`, and `meta.resourceType`), which were
+  previously accepted when absent.
+- Warn when a resource type declares a schema extension that the
+  server's `/Schemas` does not publish.
+
+### Fixed
+- Stop reporting valid responses as invalid: undeclared vendor
+  properties are now permitted, and `--attributes` relaxes required
+  checks so sparse responses pass.
+- Stop raising `KeyError` when a server's `/Schemas` omits an attribute
+  `type` or uses an unrecognized one.
+- Escape resource ids when building request URIs, which previously
+  raised `URI::InvalidURIError` for ids containing a space.
+- Resolve each resource type independently so a second `list`/`get` in
+  the same process no longer reuses the first lookup.
 
 ## [0.8.0] - 2026-03-31
 ### Changed
README.md
@@ -78,6 +78,50 @@ end
 puts user_schema.to_json
 ```
 
+## CLI
+
+The `scim-kit` executable reads a remote SCIM server and can check its
+responses against JSON Schema.
+
+```bash
+scim-kit discover --url https://example.com/scim/v2
+scim-kit list User --filter 'userName eq "bjensen"' --count 10
+scim-kit get User 2819c223-7f76-453a-919d-413861904646
+```
+
+The base URL comes from `--url` or the `SCIM_KIT_URL` environment variable.
+Pass credentials with `--header`, which may be repeated:
+
+```bash
+export SCIM_KIT_URL=https://example.com/scim/v2
+scim-kit list User --header "Authorization: Bearer $TOKEN"
+```
+
+`list` also accepts `--start-index`, `--sort-by`, `--sort-order`, and
+`--attributes`; `get` accepts `--attributes`.
+
+### Validating a server
+
+`--validate` checks responses against JSON Schema and exits non-zero when a
+response does not conform. The body is always printed to stdout; validation
+errors go to stderr.
+
+```bash
+scim-kit discover --validate
+scim-kit list User --validate
+```
+
+`discover` validates `/ServiceProviderConfig`, `/Schemas`, and
+`/ResourceTypes` against the RFC 7643 schemas bundled with this gem. `list`
+and `get` build a schema from the target server's *own* `/Schemas` document,
+so they check that a server's resources match the schema it advertises.
+
+Validation enforces what RFC 7643 §3.1 requires of a returned resource — the
+`schemas` and `id` attributes, and `meta.resourceType` when `meta` is present
+— along with the types, canonical values, and required attributes the server
+declares. Undeclared vendor properties are permitted, and `--attributes`
+relaxes the required checks so sparse responses are not reported as errors.
+
 ## Development
 
 After checking out the repo, run `bin/setup` to install dependencies. Then, run `bin/test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.