hitch-rails 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +103 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +460 -0
  5. data/SECURITY.md +118 -0
  6. data/app/controllers/concerns/hitch/cors_support.rb +97 -0
  7. data/app/controllers/concerns/hitch/host_validation.rb +51 -0
  8. data/app/controllers/concerns/hitch/issuer_url.rb +26 -0
  9. data/app/controllers/concerns/hitch/mcp/endpoint.rb +355 -0
  10. data/app/controllers/concerns/hitch/oauth_form_admission.rb +83 -0
  11. data/app/controllers/concerns/hitch/oauth_parameter_validation.rb +26 -0
  12. data/app/controllers/concerns/hitch/registration_admission.rb +115 -0
  13. data/app/controllers/concerns/hitch/request_admission.rb +46 -0
  14. data/app/controllers/concerns/hitch/uri_validation.rb +116 -0
  15. data/app/controllers/hitch/application_controller.rb +59 -0
  16. data/app/controllers/hitch/authorizations_controller.rb +152 -0
  17. data/app/controllers/hitch/metadata_controller.rb +114 -0
  18. data/app/controllers/hitch/preflights_controller.rb +14 -0
  19. data/app/controllers/hitch/public_endpoint_controller.rb +36 -0
  20. data/app/controllers/hitch/registrations_controller.rb +135 -0
  21. data/app/controllers/hitch/revocations_controller.rb +31 -0
  22. data/app/controllers/hitch/tokens_controller.rb +89 -0
  23. data/app/models/hitch/access_token.rb +267 -0
  24. data/app/models/hitch/application_record.rb +7 -0
  25. data/app/models/hitch/authorization_request.rb +252 -0
  26. data/app/models/hitch/client/credentials.rb +30 -0
  27. data/app/models/hitch/client.rb +237 -0
  28. data/app/models/hitch/client_authentication.rb +80 -0
  29. data/app/models/hitch/client_id_metadata/cache.rb +69 -0
  30. data/app/models/hitch/client_id_metadata/fetcher.rb +277 -0
  31. data/app/models/hitch/client_id_metadata/throttle.rb +119 -0
  32. data/app/models/hitch/client_id_metadata.rb +316 -0
  33. data/app/models/hitch/client_redirect_uri.rb +14 -0
  34. data/app/models/hitch/mcp/context.rb +91 -0
  35. data/app/models/hitch/mcp/forbidden.rb +10 -0
  36. data/app/models/hitch/mcp/internal/bearer_challenge.rb +51 -0
  37. data/app/models/hitch/mcp/internal/cors_policy.rb +53 -0
  38. data/app/models/hitch/mcp/internal/endpoint_error_reporter.rb +40 -0
  39. data/app/models/hitch/mcp/internal/error_normalizer.rb +74 -0
  40. data/app/models/hitch/mcp/internal/header_field.rb +31 -0
  41. data/app/models/hitch/mcp/internal/hmac_identity.rb +37 -0
  42. data/app/models/hitch/mcp/internal/host_authority.rb +51 -0
  43. data/app/models/hitch/mcp/internal/json_values.rb +182 -0
  44. data/app/models/hitch/mcp/internal/local_diagnosis.rb +31 -0
  45. data/app/models/hitch/mcp/internal/media_type.rb +61 -0
  46. data/app/models/hitch/mcp/internal/observation.rb +333 -0
  47. data/app/models/hitch/mcp/internal/registry_runtime.rb +312 -0
  48. data/app/models/hitch/mcp/internal/result_normalizer.rb +167 -0
  49. data/app/models/hitch/mcp/internal/sanitized_report.rb +36 -0
  50. data/app/models/hitch/mcp/internal/schema_contract.rb +173 -0
  51. data/app/models/hitch/mcp/internal/sdk_adapter/response_normalizer.rb +173 -0
  52. data/app/models/hitch/mcp/internal/sdk_adapter.rb +222 -0
  53. data/app/models/hitch/mcp/internal/server_info.rb +49 -0
  54. data/app/models/hitch/mcp/internal/verified_request.rb +229 -0
  55. data/app/models/hitch/mcp/internal.rb +11 -0
  56. data/app/models/hitch/mcp/rate_limit_key.rb +29 -0
  57. data/app/models/hitch/mcp/registry.rb +70 -0
  58. data/app/models/hitch/mcp/result.rb +63 -0
  59. data/app/models/hitch/mcp/tool.rb +148 -0
  60. data/app/models/hitch/oauth_request_parameters.rb +74 -0
  61. data/app/views/hitch/authorizations/new.html.erb +57 -0
  62. data/config/routes.rb +37 -0
  63. data/db/migrate/20260817000000_create_hitch_tables.rb +77 -0
  64. data/docs/operator/doctor.md +82 -0
  65. data/docs/operator/rate_limiting.md +98 -0
  66. data/docs/public_api/0.2.0.md +322 -0
  67. data/docs/removing.md +43 -0
  68. data/lib/generators/hitch/generator_guards.rb +36 -0
  69. data/lib/generators/hitch/install/install_generator.rb +168 -0
  70. data/lib/generators/hitch/install/templates/controller.rb.tt +11 -0
  71. data/lib/generators/hitch/install/templates/initializer.rb +40 -0
  72. data/lib/generators/hitch/install/templates/registry.rb +6 -0
  73. data/lib/generators/hitch/tool/templates/tool.rb.tt +54 -0
  74. data/lib/generators/hitch/tool/templates/tool_test.rb.tt +58 -0
  75. data/lib/generators/hitch/tool_generator.rb +153 -0
  76. data/lib/hitch/configuration.rb +386 -0
  77. data/lib/hitch/doctor.rb +647 -0
  78. data/lib/hitch/dynamic_registration_rate_limit.rb +75 -0
  79. data/lib/hitch/engine.rb +154 -0
  80. data/lib/hitch/mcp/configuration.rb +190 -0
  81. data/lib/hitch/mcp/protocol.rb +36 -0
  82. data/lib/hitch/mcp/test_helper.rb +203 -0
  83. data/lib/hitch/pkce.rb +18 -0
  84. data/lib/hitch/rack_form_guard.rb +109 -0
  85. data/lib/hitch/rate_limit_store.rb +47 -0
  86. data/lib/hitch/resource_uri.rb +71 -0
  87. data/lib/hitch/version.rb +5 -0
  88. data/lib/hitch-rails.rb +6 -0
  89. data/lib/hitch.rb +51 -0
  90. data/lib/tasks/hitch.rake +197 -0
  91. metadata +230 -0
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "openssl"
5
+
6
+ module Hitch
7
+ # Private fixed-window guard for unauthenticated client registration. It
8
+ # counts through the host application's own cache store; see
9
+ # Hitch::RateLimitStore.
10
+ class DynamicRegistrationRateLimit
11
+ class Unavailable < StandardError; end
12
+
13
+ class Exceeded < StandardError
14
+ attr_reader :retry_after
15
+
16
+ def initialize(retry_after)
17
+ @retry_after = retry_after
18
+ super("dynamic client registration rate limit exceeded")
19
+ end
20
+ end
21
+
22
+ SETTING = "config.dynamic_client_registration_rate_store"
23
+
24
+ class << self
25
+ def check!(remote_ip:)
26
+ configuration = Hitch.configuration
27
+ limit = configuration.dynamic_client_registration_limit
28
+ count = increment(
29
+ configuration.dynamic_client_registration_rate_store,
30
+ key_for(remote_ip),
31
+ limit.fetch(:within)
32
+ )
33
+ # A store that cannot count returns nil, the way Rails' :null_store
34
+ # does. Unlike MCP request admission, which sits behind a bearer token,
35
+ # registration is unauthenticated: an uncountable store in production
36
+ # would let anyone create unlimited clients, so refuse rather than
37
+ # admit. Production also refuses such stores at boot; this is the
38
+ # second lock on an unauthenticated write.
39
+ if count.nil?
40
+ raise Unavailable, "#{SETTING} cannot count registration attempts" if Rails.env.production?
41
+
42
+ return true
43
+ end
44
+ raise Exceeded, limit.fetch(:within) if count > limit.fetch(:to)
45
+
46
+ true
47
+ end
48
+
49
+ private
50
+
51
+ def increment(store, key, expires_in)
52
+ count = store.increment(key, 1, expires_in: expires_in)
53
+ return if count.nil?
54
+ raise Unavailable, "DCR rate store returned an invalid count" unless count.is_a?(Integer)
55
+
56
+ count
57
+ rescue Unavailable
58
+ raise
59
+ # NotImplementedError (a ScriptError): raised by the base
60
+ # ActiveSupport::Cache::Store#increment when a store never overrode it.
61
+ rescue NotImplementedError, StandardError
62
+ raise Unavailable, "DCR rate store increment failed"
63
+ end
64
+
65
+ def key_for(remote_ip)
66
+ normalized_ip = IPAddr.new(remote_ip.to_s).to_s
67
+ secret = Rails.application.secret_key_base.to_s
68
+ digest = OpenSSL::HMAC.hexdigest("SHA256", secret, normalized_ip)
69
+ "hitch:dcr:ip:#{digest}"
70
+ rescue IPAddr::InvalidAddressError
71
+ raise Unavailable, "DCR request IP could not be normalized"
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Hitch
6
+
7
+ class << self
8
+ def doctor_command?
9
+ ARGV.reject { |argument| argument.start_with?("-") } == [ "hitch:doctor" ]
10
+ end
11
+ end
12
+
13
+ initializer "hitch.zeitwerk_inflections", before: :set_autoloaders do
14
+ Rails.autoloaders.main.inflector.inflect(
15
+ "mcp" => "MCP",
16
+ "sdk_adapter" => "SDKAdapter"
17
+ )
18
+ end
19
+
20
+ # Rack::MethodOverride would otherwise parse OAuth form bodies before the
21
+ # controller-level strict admission boundary can cap or redact them.
22
+ # Anchored to ActionDispatch::Executor because it is present in both the
23
+ # full and api_only stacks (Rack::MethodOverride is not) and sits upstream
24
+ # of every body-reading middleware.
25
+ initializer "hitch.guard_oauth_forms", before: :build_middleware_stack do |app|
26
+ app.middleware.insert_after ActionDispatch::Executor, Hitch::RackFormGuard
27
+ end
28
+
29
+ # Host apps see the engine's migrations via db:migrate without needing
30
+ # to copy them — the install generator only writes the initializer.
31
+ initializer :append_migrations do |app|
32
+ next if app.root.to_s == root.to_s
33
+ # Skipped when ENGINE_ROOT is defined, which means the process was
34
+ # driven through `rails/tasks/engine.rake` — this gem's own
35
+ # Rakefile, never a host app's. ActiveRecord's own db:load_config
36
+ # hook (activerecord/railtie.rb) then appends the engine's
37
+ # db/migrate to DatabaseTasks.migrations_paths with `+=`, no
38
+ # dedupe. Contributing it here as well puts the directory in that
39
+ # collection twice, and ActiveRecord::Schema.define raises
40
+ # "Duplicate migration" as it walks them.
41
+ #
42
+ # Latent until a schema is actually reloaded — which is to say
43
+ # until someone adds a migration, at which point every subsequent
44
+ # one hits it.
45
+ next if defined?(ENGINE_ROOT)
46
+
47
+ config.paths["db/migrate"].expanded.each do |path|
48
+ app.config.paths["db/migrate"] << path
49
+ end
50
+ end
51
+
52
+ # CIMD leans on Rails.cache for negative caching, which is what stops
53
+ # a dead or hostile client_id buying an outbound request per inbound
54
+ # one. A NullStore retains nothing between requests, so that guard is
55
+ # silently absent — precisely on the deployment that thinks it is
56
+ # protected.
57
+ #
58
+ # The concurrency cap and the per-principal rate limit are both
59
+ # in-process and unaffected, which is why this warns rather than
60
+ # refuses.
61
+ # after :load_config_initializers, not :initialize_cache — the host's
62
+ # config/initializers/hitch.rb is what enables CIMD, so running any
63
+ # earlier would read the disabled default and never warn.
64
+ initializer "hitch.warn_on_uncacheable_cimd", after: :load_config_initializers do
65
+ next unless Hitch.configuration.client_id_metadata_enabled
66
+ # Production only. :null_store is Rails' default in test, and in
67
+ # development without tmp/caching-dev.txt, so warning everywhere
68
+ # would fire on every console, rake task and test run — training
69
+ # adopters to silence it in the one environment it matters.
70
+ next unless Rails.env.production?
71
+ next unless defined?(ActiveSupport::Cache::NullStore)
72
+ next unless Rails.cache.is_a?(ActiveSupport::Cache::NullStore)
73
+
74
+ Rails.logger&.warn(
75
+ "[hitch] client_id_metadata_enabled is on but Rails.cache is a NullStore. " \
76
+ "Client metadata documents will be refetched on every authorize request, and " \
77
+ "negative caching will not limit a dead or hostile client_id. The concurrency " \
78
+ "cap and per-principal rate limit are unaffected. Configure a real cache store."
79
+ )
80
+ end
81
+
82
+ initializer "hitch.validate_dynamic_client_registration", after: :load_config_initializers do
83
+ configuration = Hitch.configuration
84
+ next unless configuration.dynamic_client_registration_enabled
85
+ next if Hitch::Engine.doctor_command?
86
+
87
+ unless configuration.dynamic_client_registration_enabled_configured?
88
+ Rails.logger&.warn(
89
+ "[hitch] Dynamic Client Registration is enabled by the compatibility default. " \
90
+ "Set config.dynamic_client_registration_enabled explicitly. Production requires " \
91
+ "the registration rate store (config.dynamic_client_registration_rate_store, " \
92
+ "default: your cache store) to count across processes; new installs disable DCR."
93
+ )
94
+ end
95
+
96
+ next unless Rails.env.production?
97
+
98
+ Hitch::RateLimitStore.assert_shared!(
99
+ configuration.dynamic_client_registration_rate_store,
100
+ setting: Hitch::DynamicRegistrationRateLimit::SETTING
101
+ )
102
+ end
103
+
104
+ initializer "hitch.validate_configuration", after: :load_config_initializers do
105
+ # A fresh host has to boot once to run this generator, and the
106
+ # initializer it creates is what sets resource_uri. Keep the exception
107
+ # exact: other generators and every ordinary application boot still
108
+ # validate and fail closed.
109
+ install_generator = ARGV.first == "hitch:install" ||
110
+ (%w[generate g].include?(ARGV.first) && ARGV[1] == "hitch:install")
111
+ next if install_generator || Hitch::Engine.doctor_command?
112
+
113
+ Hitch.configuration.validate!
114
+ end
115
+
116
+ config.to_prepare do
117
+ configuration = Hitch.configuration
118
+ next if Hitch::Engine.doctor_command?
119
+ next unless configuration.resource_uri.present?
120
+ next unless configuration.mcp.enabled
121
+
122
+ configuration.mcp.validate!
123
+
124
+ configuration.mcp.prepare_registry!(supported_scopes: configuration.supported_scopes)
125
+ # Forces server_info normalization so a malformed value fails here,
126
+ # alongside registry validation, rather than on the first request.
127
+ configuration.mcp.server_info
128
+ configuration.mcp.validate_rate_limit_store!
129
+ end
130
+
131
+ # Filter OAuth secrets out of Rails request logs. Without this, a
132
+ # crash on /oauth/token would log the raw code + code_verifier
133
+ # (both lookup credentials), and a successful response would log
134
+ # the issued access_token. None should ever appear in logs.
135
+ #
136
+ # :token is included because POST /oauth/revoke receives the live
137
+ # bearer token in params[:token] (RFC 7009) — without filtering it,
138
+ # the gem's own revoke endpoint would log usable access tokens. The
139
+ # filter matches param names, so a host's unrelated :token params
140
+ # are also redacted from logs; for a secret-bearing name that is the
141
+ # safe default, not a regression.
142
+ initializer "hitch.filter_parameters" do |app|
143
+ app.config.filter_parameters += [
144
+ :code,
145
+ :code_verifier,
146
+ :client_secret,
147
+ :client_secret_digest,
148
+ :access_token,
149
+ :authorization_code,
150
+ :token
151
+ ]
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ module MCP
5
+ class Configuration
6
+ DEFAULT_MAX_REQUEST_BYTES = 1_048_576
7
+ DEFAULT_MAX_RESULT_BYTES = 1_048_576
8
+ DEFAULT_REQUEST_LIMIT = { to: 120, within: 60 }.freeze
9
+ SETTING = "mcp.rate_limit_store"
10
+
11
+ attr_reader :enabled, :registry, :scope_resolver, :request_limit,
12
+ :max_request_bytes, :max_result_bytes
13
+
14
+ def initialize
15
+ @enabled = false
16
+ @registry = nil
17
+ @registry_snapshot = nil
18
+ @registry_mutex = Mutex.new
19
+ @server_info = nil
20
+ @normalized_server_info = nil
21
+ @scope_resolver = nil
22
+ @request_limit = DEFAULT_REQUEST_LIMIT
23
+ @rate_limit_store = nil
24
+ @max_request_bytes = DEFAULT_MAX_REQUEST_BYTES
25
+ @max_result_bytes = DEFAULT_MAX_RESULT_BYTES
26
+ end
27
+
28
+ # The one explicit switch for the authenticated /mcp endpoint runtime.
29
+ def enabled=(value)
30
+ unless value == true || value == false
31
+ raise ArgumentError, "mcp.enabled must be true or false"
32
+ end
33
+
34
+ @enabled = value
35
+ end
36
+
37
+ def registry=(value)
38
+ unless value.is_a?(String) && !value.empty?
39
+ raise ArgumentError, "mcp.registry must be a nonempty String constant name"
40
+ end
41
+
42
+ @registry_mutex.synchronize do
43
+ @registry = value.dup.freeze
44
+ @registry_snapshot = nil
45
+ end
46
+ @registry
47
+ end
48
+
49
+ # Returns the validated, frozen, string-keyed identity hash. Normalized
50
+ # lazily so the reloadable validator constant is only touched after
51
+ # boot; the engine's to_prepare hook forces this read, so a malformed
52
+ # value fails the boot rather than the first request.
53
+ def server_info
54
+ @normalized_server_info ||= Hitch::MCP::Internal::ServerInfo.normalize(
55
+ @server_info || {
56
+ "name" => Rails.application.class.module_parent_name.underscore.dasherize,
57
+ "version" => "1.0.0"
58
+ }
59
+ )
60
+ end
61
+
62
+ def server_info=(value)
63
+ unless value.nil? || value.is_a?(Hash)
64
+ raise ArgumentError, "mcp.server_info must be a Hash"
65
+ end
66
+
67
+ # New value first: a reader interleaving between these two writes
68
+ # re-memoizes at worst the NEW value, never pins the old one.
69
+ @server_info = value
70
+ @normalized_server_info = nil
71
+ end
72
+
73
+ def scope_resolver=(value)
74
+ unless value.nil? || value.respond_to?(:call)
75
+ raise ArgumentError, "mcp.scope_resolver must be callable"
76
+ end
77
+
78
+ @scope_resolver = value
79
+ end
80
+
81
+ def request_limit=(value)
82
+ @request_limit = normalize_request_limit(value)
83
+ end
84
+
85
+ # Nil means "whatever this application already configured", which is what
86
+ # ActionController::RateLimiting does. Hosts that want MCP admission kept
87
+ # out of their general cache pass their own ActiveSupport::Cache store.
88
+ def rate_limit_store=(value)
89
+ @rate_limit_store = Hitch::RateLimitStore.validate!(value, setting: SETTING)
90
+ end
91
+
92
+ def rate_limit_store
93
+ Hitch::RateLimitStore.resolve(@rate_limit_store)
94
+ end
95
+
96
+ def max_request_bytes=(value)
97
+ unless value.is_a?(Integer) && value.positive?
98
+ raise ArgumentError, "mcp.max_request_bytes must be a positive integer"
99
+ end
100
+
101
+ @max_request_bytes = value
102
+ end
103
+
104
+ def max_result_bytes=(value)
105
+ unless value.is_a?(Integer) && value.positive?
106
+ raise ArgumentError, "mcp.max_result_bytes must be a positive integer"
107
+ end
108
+
109
+ @max_result_bytes = value
110
+ end
111
+
112
+ def validate!
113
+ return true unless enabled
114
+
115
+ unless registry.is_a?(String) && !registry.empty?
116
+ raise ArgumentError, "mcp.registry is required when mcp.enabled is true"
117
+ end
118
+
119
+ true
120
+ end
121
+
122
+ # Resolved separately from validate! because the application's cache store
123
+ # is assembled by Rails' own initializers; this runs from to_prepare, once
124
+ # config.cache_store is settled.
125
+ def validate_rate_limit_store!
126
+ return true unless Rails.env.production?
127
+
128
+ Hitch::RateLimitStore.assert_shared!(rate_limit_store, setting: SETTING)
129
+ end
130
+
131
+ # Framework lifecycle, not a host knob: the engine's to_prepare hook
132
+ # rebuilds the snapshot; the endpoint reads it per request.
133
+ def prepare_registry!(supported_scopes:)
134
+ @registry_mutex.synchronize do
135
+ @registry_snapshot = nil
136
+ @registry_snapshot = Hitch::MCP::Internal::RegistryRuntime.build_snapshot(
137
+ registry_name: @registry,
138
+ supported_scopes:
139
+ )
140
+ end
141
+ end
142
+
143
+ def registry_snapshot!
144
+ @registry_mutex.synchronize do
145
+ @registry_snapshot || raise(ArgumentError, "MCP registry is unavailable")
146
+ end
147
+ end
148
+
149
+ private
150
+
151
+ def normalize_request_limit(value)
152
+ unless value.respond_to?(:to_h)
153
+ raise ArgumentError, "mcp.request_limit must contain :to and :within"
154
+ end
155
+
156
+ # Duplicate keys across spellings ({ to: 1, "to" => 2 }) produce a
157
+ # third key and fail the comparison.
158
+ mapping = value.to_h
159
+ unless mapping.keys.map(&:to_s).sort == %w[to within]
160
+ raise ArgumentError, "mcp.request_limit must contain only :to and :within"
161
+ end
162
+
163
+ to = mapping[:to] || mapping["to"]
164
+ unless to.is_a?(Integer) && to.positive?
165
+ raise ArgumentError, "mcp.request_limit[:to] must be a positive Integer"
166
+ end
167
+
168
+ within = positive_duration_seconds(mapping[:within] || mapping["within"])
169
+ { to:, within: }.freeze
170
+ rescue NoMethodError, TypeError
171
+ raise ArgumentError, "mcp.request_limit must contain only :to and :within"
172
+ end
173
+
174
+ def positive_duration_seconds(value)
175
+ seconds = if defined?(ActiveSupport::Duration) && value.is_a?(ActiveSupport::Duration)
176
+ float = value.to_f
177
+ value.to_i if float.finite? && float == value.to_i
178
+ elsif value.is_a?(Integer)
179
+ value
180
+ end
181
+ unless seconds&.positive?
182
+ raise ArgumentError,
183
+ "mcp.request_limit[:within] must be a positive whole number of seconds or ActiveSupport::Duration"
184
+ end
185
+
186
+ seconds
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ module MCP
5
+ # The MCP protocol vocabulary: the one place that says which version
6
+ # Hitch speaks, which methods it answers, and what a tool may be named.
7
+ #
8
+ # Plain Ruby in lib rather than app/models, because the public test
9
+ # helper reads it at require time, before Rails has booted.
10
+ module Protocol
11
+ VERSION = "2026-07-28"
12
+
13
+ # Every JSON-RPC method the endpoint answers. Anything else is -32601
14
+ # before the SDK is built.
15
+ METHODS = %w[server/discover tools/list tools/call].freeze
16
+
17
+ # The wire name of a tool, and the shape any tool name must have before
18
+ # it may appear in a telemetry payload or an error report.
19
+ MAX_TOOL_NAME_LENGTH = 64
20
+ TOOL_NAME = /\A[A-Za-z0-9_.-]{1,#{MAX_TOOL_NAME_LENGTH}}\z/
21
+
22
+ # The only tool failure text that reaches a client when the host did
23
+ # not author one itself.
24
+ GENERIC_TOOL_ERROR = "Tool execution failed"
25
+
26
+ module_function
27
+
28
+ # instance_of?, not is_a?: a String subclass can override the methods
29
+ # every downstream reader calls, and a tool name reaches telemetry and
30
+ # error reports.
31
+ def tool_name?(value)
32
+ value.instance_of?(String) && TOOL_NAME.match?(value)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "date"
5
+ require "digest"
6
+ require "json"
7
+ require "securerandom"
8
+ require "uri"
9
+ require "hitch/mcp/protocol"
10
+
11
+ module Hitch
12
+ module MCP
13
+ module TestHelper
14
+ # Public: host test suites pin this.
15
+ PROTOCOL_VERSION = Protocol::VERSION
16
+
17
+ TOKEN_PATTERN = /\A[A-Za-z0-9\-._~+\/]+=*\z/
18
+
19
+ def mcp_headers(token:, method:, name: nil, protocol_version: PROTOCOL_VERSION)
20
+ resource = mcp_test_resource_uri!
21
+ mcp_test_headers(
22
+ resource:,
23
+ token:,
24
+ method:,
25
+ name:,
26
+ protocol_version:
27
+ )
28
+ end
29
+
30
+ def post_mcp(method:, token:, params: {}, id: "hitch-test", client_info: nil,
31
+ capabilities: {}, protocol_version: PROTOCOL_VERSION)
32
+ resource = mcp_test_resource_uri!
33
+ mcp_test_match_scheme!(resource)
34
+ normalized_params = mcp_test_json_hash(params, "params")
35
+ raise ArgumentError, "params must not supply _meta" if normalized_params.key?("_meta")
36
+
37
+ normalized_capabilities = mcp_test_json_hash(capabilities, "capabilities")
38
+ normalized_client_info = if client_info.nil?
39
+ nil
40
+ else
41
+ mcp_test_json_hash(client_info, "client_info")
42
+ end
43
+ normalized_id = mcp_test_id(id)
44
+ name = normalized_params["name"] if method == "tools/call"
45
+ metadata = {
46
+ "io.modelcontextprotocol/protocolVersion" => protocol_version.to_s,
47
+ "io.modelcontextprotocol/clientCapabilities" => normalized_capabilities
48
+ }
49
+ if normalized_client_info
50
+ metadata["io.modelcontextprotocol/clientInfo"] = normalized_client_info
51
+ end
52
+ normalized_params["_meta"] = metadata
53
+ body = JSON.generate(
54
+ "jsonrpc" => "2.0",
55
+ "id" => normalized_id,
56
+ "method" => method,
57
+ "params" => normalized_params
58
+ )
59
+
60
+ post resource.request_uri,
61
+ params: body,
62
+ headers: mcp_test_headers(
63
+ resource:,
64
+ token:,
65
+ method:,
66
+ name:,
67
+ protocol_version:
68
+ )
69
+ response
70
+ rescue JSON::GeneratorError, EncodingError => error
71
+ raise ArgumentError, "MCP test request is not valid JSON: #{error.class}"
72
+ end
73
+
74
+ # Mints a real access token through the production authorization-code
75
+ # path (create_authorization! + PKCE exchange) and returns the raw
76
+ # bearer token post_mcp expects. The principal is any persisted record
77
+ # the host treats as the signed-in user.
78
+ def mint_mcp_token(principal:, scopes: Hitch.configuration.supported_scopes,
79
+ client_id: "hitch-test-client")
80
+ scopes = Array(scopes)
81
+ unsupported = scopes - Hitch.configuration.supported_scopes
82
+ unless unsupported.empty?
83
+ # The real flow clamps grants to supported_scopes; minting past it
84
+ # would let a test pass against a grant production can never issue.
85
+ raise ArgumentError,
86
+ "scopes are not in Hitch.configuration.supported_scopes: #{unsupported.join(' ')}"
87
+ end
88
+
89
+ verifier = SecureRandom.urlsafe_base64(64)
90
+ challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
91
+ resource_uri = Hitch.configuration.resource_uri
92
+ authorization = Hitch::AccessToken.create_authorization!(
93
+ principal: principal,
94
+ client_id: client_id,
95
+ client_name: client_id,
96
+ code_challenge: challenge,
97
+ code_challenge_method: "S256",
98
+ scopes: scopes.join(" "),
99
+ resource_uri: resource_uri
100
+ )
101
+ Hitch::AccessToken.exchange_authorization_code!(
102
+ raw_code: authorization.raw_authorization_code,
103
+ code_verifier: verifier,
104
+ client_id: client_id,
105
+ resource_uri: resource_uri
106
+ ).fetch(:raw_token)
107
+ end
108
+
109
+ private
110
+
111
+ def mcp_test_resource_uri!
112
+ value = Hitch.configuration.resource_uri
113
+ raise ArgumentError, "Hitch resource_uri must be configured" unless value.is_a?(String)
114
+
115
+ resource = URI.parse(value)
116
+ unless %w[http https].include?(resource.scheme) && resource.host && !resource.fragment
117
+ raise ArgumentError, "Hitch resource_uri must be an absolute HTTP URI"
118
+ end
119
+
120
+ resource
121
+ rescue URI::InvalidURIError
122
+ raise ArgumentError, "Hitch resource_uri must be an absolute HTTP URI"
123
+ end
124
+
125
+ # The endpoint binds to the canonical resource_uri exactly — scheme
126
+ # included — and an integration test speaks http unless told otherwise.
127
+ # A host whose resource_uri is https therefore got a 400 with an empty
128
+ # body and nothing in the log, for want of one `https!`. The helper
129
+ # already derives the Host from resource_uri; it derives the scheme
130
+ # from it too.
131
+ def mcp_test_match_scheme!(resource)
132
+ return unless respond_to?(:https!)
133
+
134
+ https!(resource.scheme == "https")
135
+ end
136
+
137
+ def mcp_test_headers(resource:, token:, method:, name:, protocol_version:)
138
+ mcp_test_validate_token!(token)
139
+ mcp_test_validate_method_and_name!(method, name)
140
+ mcp_test_validate_protocol_version!(protocol_version)
141
+
142
+ {
143
+ "Host" => Hitch::ResourceUri.authority(resource),
144
+ "Authorization" => "Bearer #{token}",
145
+ "Content-Type" => "application/json",
146
+ "Accept" => "application/json, text/event-stream",
147
+ "MCP-Protocol-Version" => protocol_version,
148
+ "Mcp-Method" => method
149
+ }.tap do |headers|
150
+ headers["Mcp-Name"] = name if name
151
+ end
152
+ end
153
+
154
+ def mcp_test_validate_token!(token)
155
+ return if token.is_a?(String) && token.bytesize.between?(1, 4_096) &&
156
+ TOKEN_PATTERN.match?(token)
157
+
158
+ raise ArgumentError, "token must be a bounded bearer token"
159
+ end
160
+
161
+ def mcp_test_validate_method_and_name!(method, name)
162
+ raise ArgumentError, "method is not a supported Hitch MCP method" unless
163
+ Protocol::METHODS.include?(method)
164
+
165
+ if method == "tools/call"
166
+ raise ArgumentError, "name is required for tools/call" unless
167
+ Protocol.tool_name?(name)
168
+ elsif !name.nil?
169
+ raise ArgumentError, "name is only valid for tools/call"
170
+ end
171
+ end
172
+
173
+ def mcp_test_validate_protocol_version!(protocol_version)
174
+ raise ArgumentError, "protocol_version must be an ISO date" unless
175
+ protocol_version.is_a?(String) && protocol_version.match?(/\A\d{4}-\d{2}-\d{2}\z/)
176
+
177
+ Date.iso8601(protocol_version)
178
+ rescue Date::Error
179
+ raise ArgumentError, "protocol_version must be an ISO date"
180
+ end
181
+
182
+ def mcp_test_id(id)
183
+ return id.dup if id.is_a?(String) && !id.empty?
184
+ return id if id.is_a?(Integer)
185
+
186
+ raise ArgumentError, "id must be a nonempty String or Integer"
187
+ end
188
+
189
+ # A generate/parse round trip is the JSON normalizer: symbol keys
190
+ # become strings, structures are deep-copied so the caller's hash is
191
+ # never mutated, and cycles or non-finite numbers raise.
192
+ def mcp_test_json_hash(value, label)
193
+ raise ArgumentError, "#{label} must be a Hash" unless value.is_a?(Hash)
194
+
195
+ JSON.parse(JSON.generate(value))
196
+ rescue JSON::JSONError
197
+ raise ArgumentError, "#{label} is not plain JSON data"
198
+ end
199
+
200
+ private_constant :TOKEN_PATTERN
201
+ end
202
+ end
203
+ end
data/lib/hitch/pkce.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ module Pkce
5
+ S256_CHALLENGE = /\A[A-Za-z0-9_-]{43}\z/
6
+ VERIFIER = /\A[A-Za-z0-9\-._~]{43,128}\z/
7
+
8
+ module_function
9
+
10
+ def valid_s256_challenge?(value)
11
+ value.is_a?(String) && value.match?(S256_CHALLENGE)
12
+ end
13
+
14
+ def valid_verifier?(value)
15
+ value.is_a?(String) && value.match?(VERIFIER)
16
+ end
17
+ end
18
+ end