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
data/SECURITY.md ADDED
@@ -0,0 +1,118 @@
1
+ # Security Policy
2
+
3
+ hitch-rails is an OAuth 2.1 **authorization server**. A defect here can
4
+ expose an adopter's user accounts and every MCP tool their server
5
+ exposes, so security reports get priority over everything else in this
6
+ project.
7
+
8
+ ## Supported versions
9
+
10
+ The gem is pre-1.0. Security fixes land on `main` and ship in the next
11
+ release; only the latest release line is supported.
12
+
13
+ | Version | Supported |
14
+ | ------- | --------- |
15
+ | latest 0.2.x release, and `main` | ✅ |
16
+
17
+ The supported runtime matrix is Ruby `>= 3.3, < 4.1`, Rails `>= 8.0, < 9`,
18
+ and SQLite or PostgreSQL. Reports that reproduce only on an unsupported
19
+ runtime, adapter, or client version may still reveal a real bug, but the
20
+ maintainer will first confirm them on that matrix.
21
+
22
+ ## Reporting a vulnerability
23
+
24
+ **Do not open a public issue for a suspected vulnerability.**
25
+
26
+ Use GitHub's private vulnerability reporting:
27
+ [Security → Report a vulnerability](https://github.com/tylerklose/hitch-rails/security/advisories/new).
28
+ It's private to the maintainers, keeps the discussion attached to the
29
+ repository, and handles advisory publication and credit at the end.
30
+
31
+ If you can't use it, open a public issue that asks for a private channel
32
+ and **contains no details of the finding** — a maintainer will follow up
33
+ from there.
34
+
35
+ Helpful to include, in whatever detail you have:
36
+
37
+ - The affected endpoint or class, ideally with a `file.rb:line` reference
38
+ - A request sequence that reproduces it
39
+ - What an attacker gains — token theft, account takeover, scope
40
+ escalation, authorization bypass
41
+ - The gem version or commit SHA, and Rails version
42
+
43
+ ### What to expect
44
+
45
+ This is maintained by one person, so the honest commitment is modest and
46
+ kept rather than ambitious and missed:
47
+
48
+ - **Acknowledgement** within 5 business days
49
+ - **Assessment** — whether it's confirmed, and a rough severity — within
50
+ 10 business days
51
+ - **Coordinated disclosure.** A fix lands on `main` and an advisory is
52
+ published together. Reporters are credited unless they'd rather not be.
53
+
54
+ There is no bug bounty.
55
+
56
+ ## Scope
57
+
58
+ ### In scope
59
+
60
+ The authorization substrate the gem owns:
61
+
62
+ - Token issuance, validation, expiry, and revocation
63
+ (`Hitch::AccessToken`)
64
+ - PKCE verification and authorization-code single-use enforcement
65
+ - `redirect_uri` validation and matching (`Hitch::UriValidation`)
66
+ - RFC 8707 audience binding — a token accepted for the wrong resource
67
+ - Dynamic Client Registration — client impersonation, registration
68
+ poisoning, strict JSON admission, bounded metadata, mode enforcement, and
69
+ shared-store rate limiting
70
+ - Discovery metadata (`/.well-known/*`) — issuer or endpoint
71
+ manipulation. The issuer is fixed by `config.resource_uri`; accepted ingress
72
+ aliases and forwarded headers cannot select it.
73
+ - Pre-instrumentation size, shape, duplicate-parameter, and secret-redaction
74
+ boundaries on authorize, token, revoke, and registration requests
75
+ - Consent-screen CSRF, clickjacking, or scope escalation
76
+ - CORS policy in `Hitch::CorsSupport`
77
+ - `Hitch::MCP::Endpoint` Host/Origin/method/authentication ordering, canonical
78
+ protected-resource challenge, media/header validation, raw body cap,
79
+ duplicate-member rejection, reserved argument handling, callback isolation,
80
+ fleet-shared authenticated admission, structural notification redaction, and
81
+ stable error sanitization
82
+ - `hitch:doctor` output redaction, admission-store probe isolation/expiry/cleanup, and
83
+ its guarantee not to mutate application data or repair host configuration
84
+
85
+ ### Out of scope
86
+
87
+ - **The host application's tool implementation and business policy.** Report
88
+ tool-level authorization bugs to the application. Authentication, admission,
89
+ discovery challenges, and response shaping supplied by
90
+ `Hitch::MCP::Endpoint` remain Hitch's responsibility and are in scope.
91
+ - **Documented adopter misconfiguration.** The README's *Adopter security
92
+ requirements* section lists the host settings the gem depends on
93
+ (`allowed_hosts`, `allowed_origins`, CSRF on the consent path,
94
+ `resource_uri`, `trusted_proxies`, and a shared DCR store when DCR is
95
+ enabled in production). A report that "a host ignoring these is
96
+ exploitable" describes known, documented behavior.
97
+
98
+ A report that **following them is still insufficient** is very much in
99
+ scope — that's a gem bug, and a valuable one.
100
+ - Vulnerabilities in Rails or other dependencies, unless hitch-rails uses
101
+ them in a way that creates exposure the upstream project doesn't have.
102
+
103
+ ## Spec-conformance gaps are not vulnerabilities
104
+
105
+ This project tracks the MCP specification closely, and a gap between what
106
+ the spec requires and what the gem implements is usually a **public
107
+ issue**, not a security report.
108
+
109
+ Example: local HTTP development emits the RFC 9207 `iss` parameter so the
110
+ security path is exercised, but does not advertise support because RFC 9207
111
+ requires an HTTPS issuer. That deliberate development exception is a public
112
+ conformance limitation, not a production security promise.
113
+
114
+ The line: if exploiting it requires only a client that talks to this
115
+ server, report it privately. If it's "this doesn't match the spec text,"
116
+ open an issue and cite the section. When you're unsure which it is,
117
+ report privately — the cost of being wrong in that direction is a
118
+ redirect to the issue tracker.
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ # Exact-origin CORS support for Hitch endpoints and host controllers that
5
+ # opt in. Preflight is validated separately from ordinary response headers.
6
+ module CorsSupport
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ before_action :handle_hitch_preflight, if: -> { request.options? }
11
+ before_action :set_cors_headers, unless: -> { request.options? }
12
+ end
13
+
14
+ private
15
+
16
+ ALLOWED_REQUEST_HEADERS = [
17
+ "Content-Type",
18
+ "Authorization",
19
+ "MCP-Protocol-Version",
20
+ "Mcp-Method",
21
+ "Mcp-Name"
22
+ ].freeze
23
+
24
+ LOOPBACK_PATTERN = %r{\Ahttps?://(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?\z}.freeze
25
+
26
+ def set_cors_headers
27
+ append_vary_header("Origin")
28
+ origin = request.headers["Origin"]
29
+ return unless allowed_origin?(origin)
30
+
31
+ response.headers["Access-Control-Allow-Origin"] = origin
32
+ end
33
+
34
+ def allowed_origin?(origin)
35
+ return false unless origin.is_a?(String)
36
+ return true if Hitch.configuration.allowed_origins.include?(origin)
37
+
38
+ loopback_origins_allowed? && LOOPBACK_PATTERN.match?(origin)
39
+ end
40
+
41
+ # Called by Hitch::PreflightsController. Host-owned endpoints may call the
42
+ # same private helper from an explicit action, keeping route ownership clear.
43
+ def hitch_preflight(allowed_methods:)
44
+ append_vary_header("Origin")
45
+ append_vary_header("Access-Control-Request-Method")
46
+ append_vary_header("Access-Control-Request-Headers")
47
+
48
+ origin = request.headers["Origin"]
49
+ requested_method = request.headers["Access-Control-Request-Method"].to_s.upcase
50
+ methods = Array(allowed_methods).map { |method| method.to_s.upcase }.uniq
51
+ requested_headers = parsed_requested_headers
52
+
53
+ unless allowed_origin?(origin) && methods.include?(requested_method) && requested_headers_allowed?(requested_headers)
54
+ return head :forbidden
55
+ end
56
+
57
+ response.headers["Access-Control-Allow-Origin"] = origin
58
+ response.headers["Access-Control-Allow-Methods"] = methods.join(", ")
59
+ response.headers["Access-Control-Allow-Headers"] = ALLOWED_REQUEST_HEADERS.join(", ")
60
+ response.headers["Access-Control-Max-Age"] = "600"
61
+ head :no_content
62
+ end
63
+
64
+ def handle_hitch_preflight
65
+ methods = request.path_parameters[:target_methods].to_s.split(",").reject(&:empty?)
66
+ methods = [ "POST" ] if methods.empty?
67
+ hitch_preflight(allowed_methods: methods)
68
+ end
69
+
70
+ def parsed_requested_headers
71
+ raw = request.headers["Access-Control-Request-Headers"].to_s
72
+ return [] if raw.empty?
73
+
74
+ values = raw.split(",", -1).map(&:strip)
75
+ return nil if values.any?(&:empty?)
76
+
77
+ values
78
+ end
79
+
80
+ def requested_headers_allowed?(headers)
81
+ return false if headers.nil?
82
+
83
+ allowed = ALLOWED_REQUEST_HEADERS.map(&:downcase)
84
+ headers.all? { |header| allowed.include?(header.downcase) }
85
+ end
86
+
87
+ def loopback_origins_allowed?
88
+ Rails.env.local?
89
+ end
90
+
91
+ def append_vary_header(value)
92
+ values = response.headers["Vary"].to_s.split(",").map(&:strip).reject(&:empty?)
93
+ values << value unless values.include?(value)
94
+ response.headers["Vary"] = values.join(", ")
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Hitch
6
+ # Rejects an untrusted request origin before an engine endpoint derives
7
+ # issuer URLs, parses OAuth credentials, or performs registration work.
8
+ module HostValidation
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ # This must precede host authentication and every callback that can
13
+ # derive a public URL from the request. `prepend` keeps that ordering
14
+ # even when a host includes Hitch into a controller that already has
15
+ # application-wide before_actions.
16
+ prepend_before_action :require_allowed_hitch_host!
17
+ end
18
+
19
+ private
20
+
21
+ def require_allowed_hitch_host!
22
+ return if hitch_request_origin_allowed?
23
+
24
+ render json: {
25
+ error: "invalid_request",
26
+ error_description: "Request origin is not allowed"
27
+ }, status: :bad_request
28
+ end
29
+
30
+ def hitch_request_origin_allowed?
31
+ request_host = request.hostname.to_s.downcase
32
+ return false if request_host.empty?
33
+
34
+ resource = URI.parse(Hitch.configuration.resource_uri.to_s)
35
+ hitch_allowed_hosts(resource).include?(request_host) &&
36
+ request.scheme == resource.scheme &&
37
+ request.port == resource.port
38
+ rescue URI::InvalidURIError
39
+ false
40
+ end
41
+
42
+ def hitch_allowed_hosts(resource = URI.parse(Hitch.configuration.resource_uri.to_s))
43
+ explicit_hosts = Hitch.configuration.allowed_hosts
44
+ canonical_host = resource.hostname&.downcase
45
+
46
+ [ canonical_host, *explicit_hosts ].compact.uniq
47
+ rescue URI::InvalidURIError
48
+ explicit_hosts
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Hitch
6
+ # The authorization server's issuer identifier (RFC 8414 §2).
7
+ #
8
+ # Shared because it MUST be byte-identical everywhere it appears. The
9
+ # discovery document advertises it as `issuer`; the authorization response
10
+ # carries it as `iss` (RFC 9207); bearer challenges point back to it. Clients
11
+ # compare these values exactly.
12
+ #
13
+ # The issuer is the fixed origin of the canonical resource_uri. It never
14
+ # comes from Host or Forwarded headers. `allowed_hosts` are ingress aliases,
15
+ # not alternate issuer identities; every accepted alias advertises this same
16
+ # canonical origin.
17
+ module IssuerUrl
18
+ extend ActiveSupport::Concern
19
+
20
+ private
21
+
22
+ def issuer_url
23
+ Hitch::ResourceUri.origin(URI.parse(Hitch.configuration.resource_uri.to_s))
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,355 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Hitch
6
+ module MCP
7
+ # Authenticated, stateless MCP 2026-07-28 JSON endpoint for a dedicated
8
+ # host-owned ActionController::API controller.
9
+ module Endpoint
10
+ extend ActiveSupport::Concern
11
+
12
+ include Hitch::RequestAdmission
13
+
14
+ included do
15
+ skip_forgery_protection if respond_to?(:skip_forgery_protection)
16
+
17
+ prepend_before_action :hitch_mcp_gate!, only: :handle
18
+ prepend_around_action :hitch_mcp_observe_request,
19
+ only: :handle,
20
+ unless: -> { request.options? }
21
+ end
22
+
23
+ def handle
24
+ unless Internal::MediaType.json_content_type?(request.get_header("CONTENT_TYPE"))
25
+ hitch_mcp_explain!("Content-Type must be exactly application/json")
26
+ return hitch_mcp_protocol_error!(415, -32600, "Invalid Request")
27
+ end
28
+ unless Internal::MediaType.accepts_required_types?(request.get_header("HTTP_ACCEPT"))
29
+ hitch_mcp_explain!("Accept must admit both application/json and text/event-stream")
30
+ return hitch_mcp_protocol_error!(406, -32600, "Invalid Request")
31
+ end
32
+
33
+ max_request_bytes = Hitch.configuration.mcp.max_request_bytes
34
+ raw_body = hitch_read_bounded_request_body(max_request_bytes)
35
+ @hitch_mcp_observation&.request_bytes!(raw_body ? raw_body.bytesize : max_request_bytes + 1)
36
+ unless raw_body
37
+ hitch_mcp_explain!("request body exceeds mcp.max_request_bytes (#{max_request_bytes})")
38
+ return hitch_mcp_protocol_error!(413, -32600, "Invalid Request")
39
+ end
40
+
41
+ verified_request = Internal::VerifiedRequest.call(
42
+ raw_body: raw_body,
43
+ headers: {
44
+ protocol_version: request.get_header("HTTP_MCP_PROTOCOL_VERSION"),
45
+ method: request.get_header("HTTP_MCP_METHOD"),
46
+ name: request.get_header("HTTP_MCP_NAME")
47
+ }
48
+ )
49
+ @hitch_mcp_observation&.verified!(verified_request)
50
+
51
+ hitch_mcp_dispatch!(verified_request)
52
+ rescue Internal::VerifiedRequest::Failure => error
53
+ # The wire deliberately says only "Invalid params" for a dozen
54
+ # different rejections. Locally the first backtrace frame names the
55
+ # exact check that refused, which is the difference between a
56
+ # developer fixing their request and guessing at it.
57
+ Internal::LocalDiagnosis.report(
58
+ "MCP request rejected (#{error.code} #{error.message})", error
59
+ )
60
+ hitch_mcp_protocol_error!(
61
+ error.http_status,
62
+ error.code,
63
+ error.message,
64
+ request_id: error.request_id,
65
+ data: error.data
66
+ )
67
+ rescue StandardError => error
68
+ request_id = verified_request && verified_request["id"]
69
+ Internal::EndpointErrorReporter.report(category: :dispatch)
70
+ Internal::LocalDiagnosis.report("MCP request failed during dispatch", error)
71
+ hitch_mcp_protocol_error!(200, -32603, "Internal error", request_id: request_id)
72
+ ensure
73
+ request.delete_header("RAW_POST_DATA") if request
74
+ end
75
+
76
+ private
77
+
78
+ # ActionController instrumentation reads both body parameters and format
79
+ # before callbacks. Seed safe caches first; Hitch validates the raw body
80
+ # and Accept header itself after admission.
81
+ def process_action(action_name, ...)
82
+ if action_name.to_s == "handle"
83
+ request.request_parameters = {}
84
+ request.formats = [ :json ]
85
+ end
86
+
87
+ super
88
+ end
89
+
90
+ def hitch_mcp_observe_request
91
+ Internal::Observation.with_request_state do |state|
92
+ @hitch_mcp_observation = state
93
+ yield
94
+ end
95
+ ensure
96
+ begin
97
+ hitch_mcp_request_observed!
98
+ ensure
99
+ @hitch_mcp_observation = nil
100
+ end
101
+ end
102
+
103
+ # The security order, top to bottom. Each gate halts the request by
104
+ # rendering; nothing later runs once one has.
105
+ def hitch_mcp_gate!
106
+ hitch_mcp_host_gate!
107
+ return if performed?
108
+
109
+ hitch_mcp_origin_gate!
110
+ return if performed?
111
+
112
+ hitch_mcp_method_gate!
113
+ return if performed?
114
+
115
+ hitch_mcp_authenticate!
116
+ return if performed?
117
+
118
+ hitch_mcp_rate_admission!
119
+ end
120
+
121
+ def hitch_mcp_host_gate!
122
+ hitch_mcp_append_vary!("Origin")
123
+ if request.options?
124
+ hitch_mcp_append_vary!("Access-Control-Request-Method")
125
+ hitch_mcp_append_vary!("Access-Control-Request-Headers")
126
+ end
127
+ return if Internal::HostAuthority.allowed?(request)
128
+
129
+ hitch_mcp_explain!(
130
+ "request does not match the canonical resource_uri " \
131
+ "(#{Hitch.configuration.resource_uri}) — scheme, host, port, path and query " \
132
+ "must all match, and an integration test speaks http unless told otherwise"
133
+ )
134
+ head :bad_request
135
+ end
136
+
137
+ def hitch_mcp_origin_gate!
138
+ origin = request.get_header("HTTP_ORIGIN")
139
+ return hitch_mcp_origin_denied! if request.options? && origin.nil?
140
+ return if origin.nil?
141
+ return hitch_mcp_origin_denied! unless Internal::CorsPolicy.origin_allowed?(origin)
142
+ return if request.options?
143
+
144
+ response.headers["Access-Control-Allow-Origin"] = origin
145
+ end
146
+
147
+ def hitch_mcp_method_gate!
148
+ return hitch_mcp_preflight! if request.options?
149
+ return if request.post?
150
+
151
+ response.headers["Allow"] = "POST, OPTIONS"
152
+ head :method_not_allowed
153
+ end
154
+
155
+ def hitch_mcp_authenticate!
156
+ raw_token = Internal::BearerChallenge.token(request.get_header("HTTP_AUTHORIZATION"))
157
+ return hitch_mcp_unauthorized! unless raw_token
158
+
159
+ access_token = Hitch::AccessToken.find_by_token(raw_token)
160
+ resource = Hitch.configuration.resource_uri
161
+ client_id = access_token&.client_id
162
+ valid = access_token&.valid_for_resource?(resource) &&
163
+ client_id.is_a?(String) && !client_id.empty?
164
+ return hitch_mcp_unauthorized! unless valid
165
+
166
+ principal = hitch_mcp_token_principal(access_token)
167
+ return hitch_mcp_unauthorized! unless principal
168
+
169
+ @hitch_mcp_access_token = access_token
170
+ @hitch_mcp_principal = principal
171
+ @hitch_mcp_client_id = client_id.dup.freeze
172
+ @hitch_mcp_resource = resource.dup.freeze
173
+ @hitch_mcp_granted_scopes = access_token.scopes.to_s.split.map { |scope| scope.dup.freeze }.freeze
174
+ @hitch_mcp_observation&.authenticated!(principal:, client_id:)
175
+ rescue StandardError
176
+ Internal::EndpointErrorReporter.report(category: :authentication)
177
+ head :service_unavailable
178
+ end
179
+
180
+ def hitch_mcp_token_principal(access_token)
181
+ access_token.principal
182
+ rescue ActiveRecord::RecordNotFound, NameError
183
+ nil
184
+ end
185
+
186
+ def hitch_mcp_rate_admission!
187
+ limit = Hitch.configuration.mcp.request_limit
188
+ count = hitch_mcp_admit_authenticated_request(
189
+ principal: @hitch_mcp_principal,
190
+ client_id: @hitch_mcp_client_id
191
+ )
192
+ return if count.nil? || count <= limit.fetch(:to)
193
+
194
+ response.headers["Retry-After"] = limit.fetch(:within).to_s
195
+ response.headers["Access-Control-Expose-Headers"] = "Retry-After"
196
+ head :too_many_requests
197
+ # NotImplementedError is a ScriptError, not a StandardError: the base
198
+ # ActiveSupport::Cache::Store#increment raises it, and every subclass
199
+ # answers respond_to?(:increment), so a store that never overrode it
200
+ # passes validation and surfaces here.
201
+ rescue NotImplementedError, StandardError
202
+ Internal::EndpointErrorReporter.report(category: :request_admission)
203
+ head :service_unavailable
204
+ end
205
+
206
+ def hitch_mcp_dispatch!(verified_request)
207
+ scope = hitch_mcp_resolve_scope
208
+ context = hitch_mcp_context(verified_request, scope:)
209
+ server_info = Hitch.configuration.mcp.server_info
210
+
211
+ snapshot = Hitch.configuration.mcp.registry_snapshot!
212
+ tools = hitch_mcp_tools(verified_request:, context:, snapshot:)
213
+ return if performed?
214
+
215
+ protocol_response = Internal::SDKAdapter.call(
216
+ verified_request: verified_request,
217
+ tools: tools,
218
+ context: context,
219
+ server_info: server_info
220
+ )
221
+ hitch_mcp_render_protocol!(protocol_response, status: 200)
222
+ end
223
+
224
+ def hitch_mcp_resolve_scope
225
+ resolver = Hitch.configuration.mcp.scope_resolver
226
+ return nil unless resolver
227
+
228
+ resolver.call(
229
+ principal: @hitch_mcp_principal,
230
+ access_token: @hitch_mcp_access_token,
231
+ request: request
232
+ )
233
+ end
234
+
235
+ def hitch_mcp_context(verified_request, scope:)
236
+ metadata = verified_request.fetch("params").fetch("_meta")
237
+ Context.new(
238
+ principal: @hitch_mcp_principal,
239
+ access_token: @hitch_mcp_access_token,
240
+ scope: scope,
241
+ granted_scopes: @hitch_mcp_granted_scopes,
242
+ client_id: @hitch_mcp_client_id,
243
+ resource: @hitch_mcp_resource,
244
+ request_id: verified_request.fetch("id"),
245
+ remote_ip: request.remote_ip,
246
+ user_agent: request.user_agent,
247
+ protocol_version: metadata.fetch("io.modelcontextprotocol/protocolVersion"),
248
+ meta: metadata
249
+ )
250
+ end
251
+
252
+ def hitch_mcp_protocol_error!(status, code, message, request_id: nil, data: nil)
253
+ error = { code: code, message: message }
254
+ error[:data] = data if data
255
+ hitch_mcp_render_protocol!(
256
+ { jsonrpc: "2.0", id: request_id, error: error },
257
+ status: status
258
+ )
259
+ end
260
+
261
+ def hitch_mcp_render_protocol!(protocol_response, status:)
262
+ @hitch_mcp_observation&.protocol_response!(protocol_response)
263
+ render body: JSON.generate(protocol_response), status: status, content_type: "application/json"
264
+ end
265
+
266
+ def hitch_mcp_origin_denied!
267
+ hitch_mcp_explain!("Origin is not in config.allowed_origins")
268
+ response.headers.delete("Access-Control-Allow-Origin")
269
+ head :forbidden
270
+ end
271
+
272
+ def hitch_mcp_preflight!
273
+ allowed = Internal::CorsPolicy.preflight_allowed?(
274
+ requested_method: request.get_header("HTTP_ACCESS_CONTROL_REQUEST_METHOD"),
275
+ requested_headers: request.get_header("HTTP_ACCESS_CONTROL_REQUEST_HEADERS")
276
+ )
277
+ return hitch_mcp_origin_denied! unless allowed
278
+
279
+ response.headers["Access-Control-Allow-Origin"] = request.get_header("HTTP_ORIGIN")
280
+ Internal::CorsPolicy::PREFLIGHT_RESPONSE_HEADERS.each do |header, value|
281
+ response.headers[header] = value
282
+ end
283
+ head :no_content
284
+ end
285
+
286
+ def hitch_mcp_unauthorized!
287
+ hitch_mcp_explain!("no usable bearer token for this resource")
288
+ response.headers["WWW-Authenticate"] = Internal::BearerChallenge.challenge
289
+ response.headers["Access-Control-Expose-Headers"] = "WWW-Authenticate"
290
+ head :unauthorized
291
+ end
292
+
293
+ def hitch_mcp_insufficient_scope!(required_scopes)
294
+ response.headers["WWW-Authenticate"] =
295
+ Internal::BearerChallenge.insufficient_scope(required_scopes)
296
+ response.headers["Access-Control-Expose-Headers"] = "WWW-Authenticate"
297
+ head :forbidden
298
+ end
299
+
300
+ # Every refusal above answers with an empty body on purpose. In
301
+ # development and test the reason goes to the local log instead, so a
302
+ # 400 is a sentence rather than a silence.
303
+ def hitch_mcp_explain!(reason)
304
+ Internal::LocalDiagnosis.report("MCP request refused: #{reason}")
305
+ end
306
+
307
+ def hitch_mcp_append_vary!(value)
308
+ values = response.headers["Vary"].to_s.split(",").map(&:strip).reject(&:empty?)
309
+ values << value unless values.include?(value)
310
+ response.headers["Vary"] = values.join(", ")
311
+ end
312
+
313
+ def hitch_mcp_tools(verified_request:, context:, snapshot:)
314
+ if verified_request.fetch("method") == "tools/call"
315
+ resolution = Internal::RegistryRuntime.runtime_call(
316
+ snapshot:,
317
+ name: verified_request.fetch("params").fetch("name"),
318
+ context:
319
+ )
320
+ if %i[available insufficient_scope].include?(resolution.status)
321
+ @hitch_mcp_observation&.tool_resolved!(verified_request.fetch("params").fetch("name"))
322
+ end
323
+ if resolution.status == :insufficient_scope
324
+ hitch_mcp_insufficient_scope!(resolution.required_scopes)
325
+ return [].freeze
326
+ end
327
+
328
+ return resolution.status == :available ? [ resolution.tool ].freeze : [].freeze
329
+ end
330
+
331
+ Internal::RegistryRuntime.runtime_listing(snapshot:, context:)
332
+ end
333
+
334
+ # Counts through the host application's own cache store, exactly as
335
+ # ActionController::RateLimiting does. A nil count admits, same as
336
+ # Rails: :null_store returns nil (test, and development without
337
+ # caching — production refuses those stores at boot), and Redis and
338
+ # Solid Cache stores return nil during a backend outage rather than
339
+ # raising. This request is already authenticated, so an outage widens
340
+ # one token holder's quota, not the front door. Anything else a store
341
+ # returns fails the comparison above and becomes a 503.
342
+ def hitch_mcp_admit_authenticated_request(principal:, client_id:)
343
+ configuration = Hitch.configuration.mcp
344
+ configuration.rate_limit_store.increment(
345
+ RateLimitKey.call(principal:, client_id:),
346
+ expires_in: configuration.request_limit.fetch(:within)
347
+ )
348
+ end
349
+
350
+ def hitch_mcp_request_observed!
351
+ @hitch_mcp_observation&.finish!(response:)
352
+ end
353
+ end
354
+ end
355
+ end