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,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Hitch
6
+ # Client ID Metadata Documents (CIMD).
7
+ #
8
+ # MCP 2026-07-28 deprecates Dynamic Client Registration in favour of
9
+ # CIMD: instead of pre-registering and receiving an opaque client_id,
10
+ # a client uses an https URL as its client_id, and the authorization
11
+ # server fetches the client metadata from that URL.
12
+ #
13
+ # That inverts the trust model. DCR data arrives on a request the
14
+ # server is already handling; CIMD makes the AUTHORIZATION SERVER issue
15
+ # an outbound request to a URL the caller chose. /oauth/authorize
16
+ # requires a signed-in principal, so the caller is authenticated rather
17
+ # than anonymous — a low bar on any host with open sign-up, and note
18
+ # the GET consent path carries no CSRF token, so a fetch can be driven
19
+ # from a logged-in victim's browser. Every guard here exists because of
20
+ # that inversion:
21
+ #
22
+ # - https only, no redirects followed, no userinfo, no fragment
23
+ # - DNS resolved once, every address checked against a blocklist of
24
+ # non-public ranges, then the connection PINNED to the checked
25
+ # address via Net::HTTP#ipaddr= so a second lookup can't return a
26
+ # different answer (DNS rebinding)
27
+ # - hard caps on time and response size
28
+ # - the document's own `client_id` must equal the URL it came from,
29
+ # so a document cannot claim to be a different client
30
+ # - successes and failures are both cached, so a hostile or dead URL
31
+ # cannot be used to make the authorize endpoint issue an outbound
32
+ # request per inbound request. Note this one depends on the host
33
+ # having a real Rails.cache: under a NullStore (Rails' default in
34
+ # test, and in development without tmp/caching-dev.txt) nothing is
35
+ # retained between requests and the amplification guard is absent.
36
+ #
37
+ # Disabled unless the host opts in (`config.client_id_metadata_enabled`).
38
+ # The feature adds an outbound-fetch surface to an endpoint that had
39
+ # none, and DCR still works, so it is not something to switch on for an
40
+ # adopter who has not considered it.
41
+ class ClientIdMetadata
42
+ Document = Struct.new(:client_id, :client_name, :redirect_uris, keyword_init: true)
43
+
44
+ # CIMD documents live on ordinary https endpoints. Allowing an
45
+ # arbitrary port would let a caller drive TLS connections to any
46
+ # host:port from the authorization server's egress address — the
47
+ # standard way around a third party's source-IP allowlist.
48
+ ALLOWED_PORT = 443
49
+
50
+ # Process-wide by design: both throttle bounds are this process's share
51
+ # of outbound work (see Throttle).
52
+ @throttle = Throttle.new
53
+
54
+ # Failure sentinels (see Fetcher and Throttle for the semantics).
55
+ HOST_FAILURE = Fetcher::HOST_FAILURE
56
+ # Refused because a cap was already spent — no fetch was attempted, so
57
+ # nothing is known and nothing may be cached (see Throttle).
58
+ CAPACITY_EXCEEDED = Throttle::CAPACITY_EXCEEDED
59
+ # Refused because this principal spent its minute budget. Same rule.
60
+ RATE_LIMITED = :rate_limited
61
+
62
+ # Result of a diagnostic fetch. Separate from Document deliberately:
63
+ # this is operator-facing and describes an attempt, not a client.
64
+ Diagnosis = Struct.new(:outcome, :detail, keyword_init: true) do
65
+ def ok? = outcome == :ok
66
+ end
67
+
68
+ class << self
69
+ # A client_id is a CIMD reference when it is an https URL. Opaque
70
+ # DCR client_ids (UUIDs) never match, so the two schemes coexist
71
+ # without ambiguity.
72
+ def reference?(client_id)
73
+ return false unless Hitch.configuration.client_id_metadata_enabled
74
+
75
+ document_url?(client_id)
76
+ end
77
+
78
+ # The shape half of reference?, without consulting the enablement
79
+ # flag. Split out so the operator diagnostic can run BEFORE CIMD is
80
+ # switched on — which is the only moment its answer is useful.
81
+ #
82
+ # "The client_id URL MUST use the 'https' scheme and contain a path
83
+ # component" — MCP 2026-07-28, Client Registration. A bare origin
84
+ # is not a metadata document URL, so it falls through to the
85
+ # opaque/DCR lookup rather than triggering an outbound fetch.
86
+ def document_url?(client_id)
87
+ return false if client_id.blank?
88
+
89
+ uri = URI.parse(client_id.to_s)
90
+ uri.is_a?(URI::HTTPS) && uri.host.present? && uri.path.present? && uri.path != "/"
91
+ rescue URI::InvalidURIError
92
+ false
93
+ end
94
+
95
+ # Returns a Document, or nil for anything that isn't a usable
96
+ # client metadata document. Never raises into the authorize flow:
97
+ # a fetch failure is an untrusted client's problem, not a 500.
98
+ # `actor` identifies the signed-in principal driving this
99
+ # resolution, for per-actor rate limiting. Optional: omitted, only
100
+ # the concurrency cap applies.
101
+ def resolve(client_id, actor: nil)
102
+ return nil unless reference?(client_id)
103
+
104
+ key = Cache.key(client_id)
105
+ cached = Cache.read(key)
106
+
107
+ unless cached.nil?
108
+ return nil if cached == false
109
+
110
+ document = Cache.rehydrate(cached)
111
+ return document if document
112
+
113
+ # An entry we can't read is treated as a miss rather than
114
+ # propagating. A Document member added in a later release, a
115
+ # rolling deploy sharing a cache between two versions, or a
116
+ # host configuring a coder that stringifies keys would
117
+ # otherwise turn /oauth/authorize into a 500 for that
118
+ # client_id until the TTL expired.
119
+ Cache.delete(key)
120
+ end
121
+
122
+ # Shape is judged BEFORE either cap is touched. Rejecting a URL on
123
+ # its scheme, port, userinfo or fragment costs nothing outbound,
124
+ # so charging it would let a caller spend their own minute budget
125
+ # on requests that never sent a packet — and then be refused a
126
+ # legitimate fetch. Shape rejects are never cached, either:
127
+ # repeating the check is free, while writing an entry per
128
+ # malformed client_id lets a caller fill a shared cache —
129
+ # evicting the host app's own entries — without sending a single
130
+ # packet.
131
+ target = fetch_target(client_id)
132
+ return nil if target.nil?
133
+
134
+ # A host that just failed to answer at all is not retried,
135
+ # whatever path or query is hung off it. Keyed by URL alone the
136
+ # negative cache is defeated by appending ?n=1, ?n=2 — each a
137
+ # distinct key and each a valid CIMD reference.
138
+ host = target.host
139
+ return nil if Cache.read(Cache.failure_key(host)) == false
140
+
141
+ # Both caps are consulted only on a genuine miss. A cached
142
+ # resolution costs nothing outbound, so charging it against
143
+ # either budget would penalise the common case and make a busy,
144
+ # correctly-configured server throttle itself.
145
+ #
146
+ # Capacity is taken FIRST, and the minute budget is only charged
147
+ # once a slot is held. The other order spends a token on a
148
+ # request that never sent a packet — which turns a squeeze on the
149
+ # slots into a way to drain every victim's own budget while they
150
+ # retry, locking them out past the point where the slots free up.
151
+ outcome = with_fetch_capacity do
152
+ charge_rate_limit(actor) ? Fetcher.call(client_id, target) : RATE_LIMITED
153
+ end
154
+
155
+ case outcome
156
+ when CAPACITY_EXCEEDED, RATE_LIMITED
157
+ # Deliberately no cache write of any kind — see the constants.
158
+ nil
159
+ when Array
160
+ # [document, ttl] — the TTL is derived from the document's own
161
+ # HTTP cache headers, clamped by config.
162
+ document, ttl = outcome
163
+ Cache.write(key, document.to_h, ttl) if ttl.positive?
164
+ document
165
+ when HOST_FAILURE
166
+ Cache.write(key, false, Cache::FAILURE_TTL)
167
+ Cache.write(Cache.failure_key(host), false, Cache::FAILURE_TTL)
168
+ nil
169
+ else
170
+ # A document-level failure — 404, malformed JSON, a document
171
+ # naming the wrong client_id. It says nothing about its
172
+ # neighbours, so it must NOT block them: one domain hosting
173
+ # many client documents is the normal CIMD deployment shape,
174
+ # and poisoning the host on a per-document failure would let
175
+ # anyone hold that whole domain offline by requesting a single
176
+ # bogus URL on it once a minute.
177
+ Cache.write(key, false, Cache::FAILURE_TTL)
178
+ nil
179
+ end
180
+ end
181
+
182
+ # Number of fetches in flight right now. Test seam.
183
+ def fetches_in_flight
184
+ @throttle.in_flight
185
+ end
186
+
187
+ # Operator-facing check that this host can actually reach and parse a
188
+ # client metadata document, for confirming egress before enabling
189
+ # CIMD. Takes a URL the operator already trusts.
190
+ #
191
+ # Reports only. Whether one document is reachable right now is a
192
+ # different question from whether this server supports CIMD, and
193
+ # only the second belongs in the discovery document: a capability
194
+ # that moved with network conditions would be stale for up to the
195
+ # discovery cache lifetime, and would tell clients nothing they
196
+ # could act on.
197
+ #
198
+ # Skips the caches and the per-principal limit (there is no
199
+ # principal) but not the SSRF constraints or the concurrency cap —
200
+ # exercising the real fetch path is the entire point.
201
+ def diagnose(client_id)
202
+ # Deliberately ignores client_id_metadata_enabled. The whole point
203
+ # is to answer "can this host reach a document?" BEFORE deciding
204
+ # to turn CIMD on, so gating the probe on the setting it informs
205
+ # makes it useless exactly when it is needed. That flag governs
206
+ # discovery and real authorization traffic; it does not govern an
207
+ # operator running a command.
208
+ unless document_url?(client_id)
209
+ return Diagnosis.new(outcome: :not_a_reference,
210
+ detail: "not an https URL with a path component, so it would be treated as an opaque client_id")
211
+ end
212
+
213
+ target = fetch_target(client_id)
214
+ if target.nil?
215
+ return Diagnosis.new(outcome: :rejected_shape,
216
+ detail: "must be https on port #{ALLOWED_PORT}, with no userinfo and no fragment")
217
+ end
218
+
219
+ case (outcome = with_fetch_capacity { Fetcher.call(client_id, target) })
220
+ when Array
221
+ Diagnosis.new(outcome: :ok, detail: "resolved #{outcome.first.redirect_uris.length} redirect_uri(s)")
222
+ when CAPACITY_EXCEEDED
223
+ Diagnosis.new(outcome: :no_capacity, detail: "every fetch slot is currently busy")
224
+ when HOST_FAILURE
225
+ Diagnosis.new(outcome: :unreachable,
226
+ detail: "DNS, connect, TLS or timeout failed — check direct egress on port #{ALLOWED_PORT}; " \
227
+ "an ambient http_proxy is deliberately ignored")
228
+ else
229
+ Diagnosis.new(outcome: :invalid_document,
230
+ detail: "the host answered but the document was unusable — the log line for this URL says why")
231
+ end
232
+ end
233
+
234
+ private
235
+
236
+ # The limit is read from config at acquisition time — a host may
237
+ # change it, and tests do.
238
+ def with_fetch_capacity(&block)
239
+ @throttle.with_capacity(integer_setting(:client_id_metadata_max_concurrent_fetches), &block)
240
+ end
241
+
242
+ def charge_rate_limit(actor)
243
+ limit = integer_setting(:client_id_metadata_fetches_per_minute)
244
+ # nil disables. 0 and below block, matching the concurrency knob —
245
+ # the most restrictive-looking setting must not be the one that
246
+ # removes the protection.
247
+ return true if limit.nil?
248
+ return false if limit <= 0
249
+
250
+ if actor.blank?
251
+ # Not reachable from the shipped controller — both authorize
252
+ # actions bail to require_principal! first — but a host whose
253
+ # principal_method returns something without #id (a claims
254
+ # hash, a bare identifier) would land here and silently get no
255
+ # rate limiting at all.
256
+ warn_once(:cimd_rate_limit_no_actor,
257
+ "client_id_metadata_fetches_per_minute is set but the resolution had no actor; " \
258
+ "per-principal rate limiting is not being applied")
259
+ return true
260
+ end
261
+
262
+ @throttle.charge(actor, limit: limit)
263
+ end
264
+
265
+ # Test seam: current count for an actor in this minute.
266
+ def fetches_charged_to(actor)
267
+ @throttle.charged_to(actor)
268
+ end
269
+
270
+ # Reads a numeric setting without trusting its type. The docs say
271
+ # "nil disables", and the obvious wrong guess at that is `false` —
272
+ # whose #to_i does not exist, which would raise NoMethodError
273
+ # straight out of resolve and 500 /oauth/authorize on the
274
+ # default-on path. Anything not coercible to an Integer is treated
275
+ # as unset rather than fatal.
276
+ def integer_setting(name)
277
+ case (value = Hitch.configuration.public_send(name))
278
+ when Integer then value
279
+ # Strings are accepted because settings often arrive from ENV.
280
+ # Floats are NOT: Kernel.Integer(2.5) truncates to 2, which would
281
+ # silently honour a value the docs say is unset.
282
+ when String then Integer(value, exception: false)
283
+ end
284
+ end
285
+
286
+ # Warns once per process per reason. These describe a standing
287
+ # misconfiguration, not a per-request event; logging them on every
288
+ # authorize would bury the thing it is warning about.
289
+ def warn_once(reason, message)
290
+ @warned ||= {}
291
+ return if @warned[reason]
292
+
293
+ @warned[reason] = true
294
+ Rails.logger&.warn("[hitch] #{message}")
295
+ rescue StandardError
296
+ nil
297
+ end
298
+
299
+ # Parses a client_id into the URI to fetch, or nil when its shape
300
+ # rules it out. Deliberately separate from the Fetcher call and
301
+ # called before the caps: none of these checks costs a packet, so
302
+ # none of them should cost a token.
303
+ def fetch_target(client_id)
304
+ uri = URI.parse(client_id)
305
+ return nil if uri.userinfo.present? || uri.fragment.present?
306
+ return nil unless uri.port == ALLOWED_PORT
307
+
308
+ uri
309
+ rescue URI::InvalidURIError
310
+ # Unreachable in practice: both callers gate on document_url?,
311
+ # which already parsed this exact string.
312
+ nil
313
+ end
314
+ end
315
+ end
316
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ class ClientRedirectUri < ApplicationRecord
5
+ self.table_name = "hitch_client_redirect_uris"
6
+
7
+ belongs_to :client,
8
+ class_name: "Hitch::Client",
9
+ foreign_key: :hitch_client_id,
10
+ inverse_of: :redirect_uri_records
11
+
12
+ validates :uri, presence: true, uniqueness: { scope: :hitch_client_id }
13
+ end
14
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ module MCP
5
+ # Frozen, request-local authority envelope passed to host MCP policy and
6
+ # behavior. Host and Active Record references are intentionally preserved
7
+ # as opaque references; freezing this envelope does not claim to freeze
8
+ # those objects.
9
+ class Context
10
+ attr_reader :principal, :access_token, :scope, :granted_scopes, :client_id,
11
+ :resource, :request_id, :remote_ip, :user_agent, :protocol_version, :meta
12
+
13
+ def initialize(
14
+ principal:,
15
+ access_token:,
16
+ scope:,
17
+ granted_scopes:,
18
+ client_id:,
19
+ resource:,
20
+ request_id:,
21
+ remote_ip:,
22
+ user_agent:,
23
+ protocol_version:,
24
+ meta:
25
+ )
26
+ raise ArgumentError, "principal is required" if principal.nil?
27
+ raise ArgumentError, "access_token is required" if access_token.nil?
28
+
29
+ @principal = principal
30
+ @access_token = access_token
31
+ @scope = scope
32
+ @granted_scopes = copy_scopes(granted_scopes)
33
+ @client_id = copy_required_string(client_id, "client_id")
34
+ @resource = copy_required_string(resource, "resource")
35
+ @request_id = copy_request_id(request_id)
36
+ @remote_ip = copy_required_string(remote_ip, "remote_ip")
37
+ @user_agent = copy_optional_string(user_agent, "user_agent")
38
+ @protocol_version = copy_required_string(protocol_version, "protocol_version")
39
+ @meta = copy_meta(meta)
40
+ freeze
41
+ end
42
+
43
+ private
44
+
45
+ def copy_scopes(values)
46
+ raise ArgumentError, "granted_scopes must be an Array" unless values.is_a?(Array)
47
+
48
+ values.map do |value|
49
+ copy_required_string(value, "granted_scopes entries")
50
+ end.freeze
51
+ end
52
+
53
+ def copy_request_id(value)
54
+ unless value.is_a?(String) || value.is_a?(Numeric)
55
+ raise ArgumentError, "request_id must be a String or Numeric"
56
+ end
57
+
58
+ value.is_a?(String) ? value.dup.freeze : value
59
+ end
60
+
61
+ def copy_required_string(value, name)
62
+ unless value.is_a?(String) && !value.empty?
63
+ raise ArgumentError, "#{name} must be a nonempty String"
64
+ end
65
+
66
+ value.dup.freeze
67
+ end
68
+
69
+ def copy_optional_string(value, name)
70
+ return if value.nil?
71
+
72
+ raise ArgumentError, "#{name} must be a String or nil" unless value.is_a?(String)
73
+
74
+ value.dup.freeze
75
+ end
76
+
77
+ def copy_meta(value)
78
+ raise ArgumentError, "meta must be a Hash" unless value.is_a?(Hash)
79
+
80
+ Internal::JsonValues.copy(
81
+ value,
82
+ keys: :string, symbols: :reject, foreign: :reject, freeze: true,
83
+ on_invalid: lambda do |reason, _detail|
84
+ raise ArgumentError,
85
+ (reason == :key ? "meta keys must be Strings" : "meta must contain only JSON values")
86
+ end
87
+ )
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ module MCP
5
+ # Raised by host argument policy to deny one otherwise admissible tool call.
6
+ # Its message is always private and never crosses the MCP boundary.
7
+ class Forbidden < StandardError
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Hitch
6
+ module MCP
7
+ module Internal
8
+ # Bearer token extraction and the WWW-Authenticate challenges the
9
+ # endpoint issues (RFC 6750 §3, RFC 9728 protected-resource metadata).
10
+ module BearerChallenge
11
+ MAX_BEARER_TOKEN_BYTES = 512
12
+
13
+ module_function
14
+
15
+ def token(authorization)
16
+ authorization = authorization.to_s
17
+ return if authorization.bytesize > MAX_BEARER_TOKEN_BYTES + 7
18
+ return unless authorization.valid_encoding?
19
+ return if authorization.match?(/[\u0000-\u001F\u007F-\u009F]/)
20
+
21
+ match = authorization.match(/\ABearer ([A-Za-z0-9_-]{1,#{MAX_BEARER_TOKEN_BYTES}})\z/i)
22
+ match && match[1]
23
+ end
24
+
25
+ # A generic 401 starts the least-privilege authorization flow with the
26
+ # host's base/default scope. Protected-resource metadata still
27
+ # advertises the complete supported set, and a known available tool
28
+ # names its complete static requirement in a later 403 step-up.
29
+ def challenge
30
+ scope = Hitch.configuration.supported_scopes.first
31
+ %(Bearer resource_metadata="#{resource_metadata_url}", scope="#{scope}")
32
+ end
33
+
34
+ def insufficient_scope(required_scopes)
35
+ "Bearer error=\"insufficient_scope\", " \
36
+ "scope=\"#{required_scopes.join(' ')}\", " \
37
+ "resource_metadata=\"#{resource_metadata_url}\""
38
+ end
39
+
40
+ # Derived from the canonical resource_uri, not handed in: the issuer
41
+ # inside a challenge must be the same bytes discovery advertises, and
42
+ # the one derivation is what guarantees it (INV-MCP-024).
43
+ def resource_metadata_url
44
+ Hitch::ResourceUri.protected_resource_metadata_url(
45
+ URI.parse(Hitch.configuration.resource_uri.to_s)
46
+ )
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ module MCP
5
+ module Internal
6
+ # CORS decisions for the endpoint: an exact origin allowlist (plus
7
+ # loopback in development and test), and a fixed preflight contract —
8
+ # POST only, a closed request-header set.
9
+ module CorsPolicy
10
+ ALLOWED_REQUEST_HEADERS = %w[
11
+ Content-Type
12
+ Authorization
13
+ MCP-Protocol-Version
14
+ Mcp-Method
15
+ Mcp-Name
16
+ ].freeze
17
+ LOOPBACK_ORIGIN = %r{\Ahttps?://(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?\z}
18
+ PREFLIGHT_RESPONSE_HEADERS = {
19
+ "Access-Control-Allow-Methods" => "POST",
20
+ "Access-Control-Allow-Headers" => ALLOWED_REQUEST_HEADERS.join(", "),
21
+ "Access-Control-Max-Age" => "600"
22
+ }.freeze
23
+
24
+ module_function
25
+
26
+ def origin_allowed?(origin)
27
+ return false unless origin.is_a?(String) && origin.valid_encoding?
28
+ return false if origin.empty? || origin.include?(",") || HeaderField::CONTROLS.match?(origin)
29
+ return true if Hitch.configuration.allowed_origins.include?(origin)
30
+
31
+ Rails.env.local? && LOOPBACK_ORIGIN.match?(origin)
32
+ end
33
+
34
+ def preflight_allowed?(requested_method:, requested_headers:)
35
+ method = HeaderField.single(requested_method)
36
+ headers = requested_header_list(requested_headers)
37
+ return false unless method == "POST" && headers
38
+
39
+ allowed = ALLOWED_REQUEST_HEADERS.map(&:downcase)
40
+ headers.all? { |header| allowed.include?(header.downcase) }
41
+ end
42
+
43
+ def requested_header_list(value)
44
+ return [] if value.nil? || value.empty?
45
+ return unless value.is_a?(String) && value.valid_encoding? && !HeaderField::CONTROLS.match?(value)
46
+
47
+ values = value.split(",", -1).map { |entry| HeaderField.trim_ows(entry) }
48
+ values unless values.any? { |entry| entry.nil? || entry.empty? }
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ module MCP
5
+ module Internal
6
+ # Reports an endpoint failure without ever accepting the original
7
+ # exception. Authentication inputs, request bodies, and host state
8
+ # therefore cannot cross this reporting boundary by accident.
9
+ class EndpointErrorReporter
10
+ SOURCE = "hitch.mcp.endpoint"
11
+ CATEGORIES = {
12
+ authentication: "authentication",
13
+ request_admission: "request_admission",
14
+ dispatch: "dispatch"
15
+ }.freeze
16
+
17
+ class << self
18
+ def report(category:)
19
+ SanitizedReport.emit(
20
+ source: SOURCE,
21
+ message: "Hitch MCP endpoint failed",
22
+ context: reporting_context(category)
23
+ )
24
+ rescue StandardError, SystemStackError
25
+ nil
26
+ end
27
+
28
+ private
29
+
30
+ def reporting_context(category)
31
+ context = { hitch_mcp_category: CATEGORIES.fetch(category) }
32
+ request_id = Observation.current_request_id
33
+ context[:hitch_mcp_request_id] = request_id.dup.freeze if request_id
34
+ context.freeze
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+
5
+ module Hitch
6
+ module MCP
7
+ module Internal
8
+ # Reports only a synthetic failure with fixed structural context, then
9
+ # returns the same generic tool error for every non-explicit failure.
10
+ class ErrorNormalizer
11
+ SOURCE = "hitch.mcp.tool"
12
+ PHASE_CATEGORIES = {
13
+ context: "context_handoff",
14
+ arguments: "argument_normalization",
15
+ authorization: "argument_policy",
16
+ execution: "host_execution",
17
+ result: "result_normalization"
18
+ }.freeze
19
+
20
+ class << self
21
+ def call(error:, phase:, context:, tool_name:)
22
+ unless expected_denial?(error, phase)
23
+ report(error:, phase:, tool_name:)
24
+ log_local_diagnosis(error:, phase:, tool_name:)
25
+ end
26
+ generic_response
27
+ rescue StandardError, SystemStackError
28
+ generic_response
29
+ end
30
+
31
+ private
32
+
33
+ def expected_denial?(error, phase)
34
+ phase == :authorization && error.is_a?(Forbidden)
35
+ end
36
+
37
+ def log_local_diagnosis(error:, phase:, tool_name:)
38
+ category = ResultNormalizer.failure_category(error)
39
+ LocalDiagnosis.report(
40
+ "MCP tool #{tool_name.inspect} failed during #{phase}#{" (#{category})" if category}",
41
+ error
42
+ )
43
+ end
44
+
45
+ def report(error:, phase:, tool_name:)
46
+ SanitizedReport.emit(
47
+ source: SOURCE,
48
+ message: "Hitch MCP tool execution failed",
49
+ context: reporting_context(error:, phase:, tool_name:)
50
+ )
51
+ end
52
+
53
+ def reporting_context(error:, phase:, tool_name:)
54
+ category = ResultNormalizer.failure_category(error) || PHASE_CATEGORIES.fetch(phase, "tool_boundary")
55
+ context = { hitch_mcp_category: category.to_s.freeze }
56
+ if Protocol.tool_name?(tool_name)
57
+ context[:hitch_mcp_tool] = tool_name.dup.freeze
58
+ end
59
+ request_id = Observation.current_request_id
60
+ context[:hitch_mcp_request_id] = request_id.dup.freeze if request_id
61
+ context.freeze
62
+ end
63
+
64
+ def generic_response
65
+ ::MCP::Tool::Response.new(
66
+ [ { type: "text", text: Protocol::GENERIC_TOOL_ERROR } ],
67
+ error: true
68
+ )
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ module MCP
5
+ module Internal
6
+ # Field-value hygiene shared by the endpoint and the verified request:
7
+ # reject control bytes and comma-combined values, trim optional
8
+ # whitespace (RFC 9110 §5.5-5.6).
9
+ module HeaderField
10
+ CONTROLS = /[\x00-\x08\x0A-\x1F\x7F]/
11
+ OWS = /\A[\x20\x09]*(.*?)[\x20\x09]*\z/m
12
+
13
+ module_function
14
+
15
+ # The one exact value of a header that must not repeat: nil for
16
+ # missing, invalid, comma-combined, or empty-after-trim values.
17
+ def single(value)
18
+ return unless value.is_a?(String) && value.valid_encoding?
19
+ return if value.include?(",") || CONTROLS.match?(value)
20
+
21
+ candidate = trim_ows(value)
22
+ candidate unless candidate.nil? || candidate.empty?
23
+ end
24
+
25
+ def trim_ows(value)
26
+ value[OWS, 1]
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end