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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +103 -0
- data/MIT-LICENSE +20 -0
- data/README.md +460 -0
- data/SECURITY.md +118 -0
- data/app/controllers/concerns/hitch/cors_support.rb +97 -0
- data/app/controllers/concerns/hitch/host_validation.rb +51 -0
- data/app/controllers/concerns/hitch/issuer_url.rb +26 -0
- data/app/controllers/concerns/hitch/mcp/endpoint.rb +355 -0
- data/app/controllers/concerns/hitch/oauth_form_admission.rb +83 -0
- data/app/controllers/concerns/hitch/oauth_parameter_validation.rb +26 -0
- data/app/controllers/concerns/hitch/registration_admission.rb +115 -0
- data/app/controllers/concerns/hitch/request_admission.rb +46 -0
- data/app/controllers/concerns/hitch/uri_validation.rb +116 -0
- data/app/controllers/hitch/application_controller.rb +59 -0
- data/app/controllers/hitch/authorizations_controller.rb +152 -0
- data/app/controllers/hitch/metadata_controller.rb +114 -0
- data/app/controllers/hitch/preflights_controller.rb +14 -0
- data/app/controllers/hitch/public_endpoint_controller.rb +36 -0
- data/app/controllers/hitch/registrations_controller.rb +135 -0
- data/app/controllers/hitch/revocations_controller.rb +31 -0
- data/app/controllers/hitch/tokens_controller.rb +89 -0
- data/app/models/hitch/access_token.rb +267 -0
- data/app/models/hitch/application_record.rb +7 -0
- data/app/models/hitch/authorization_request.rb +252 -0
- data/app/models/hitch/client/credentials.rb +30 -0
- data/app/models/hitch/client.rb +237 -0
- data/app/models/hitch/client_authentication.rb +80 -0
- data/app/models/hitch/client_id_metadata/cache.rb +69 -0
- data/app/models/hitch/client_id_metadata/fetcher.rb +277 -0
- data/app/models/hitch/client_id_metadata/throttle.rb +119 -0
- data/app/models/hitch/client_id_metadata.rb +316 -0
- data/app/models/hitch/client_redirect_uri.rb +14 -0
- data/app/models/hitch/mcp/context.rb +91 -0
- data/app/models/hitch/mcp/forbidden.rb +10 -0
- data/app/models/hitch/mcp/internal/bearer_challenge.rb +51 -0
- data/app/models/hitch/mcp/internal/cors_policy.rb +53 -0
- data/app/models/hitch/mcp/internal/endpoint_error_reporter.rb +40 -0
- data/app/models/hitch/mcp/internal/error_normalizer.rb +74 -0
- data/app/models/hitch/mcp/internal/header_field.rb +31 -0
- data/app/models/hitch/mcp/internal/hmac_identity.rb +37 -0
- data/app/models/hitch/mcp/internal/host_authority.rb +51 -0
- data/app/models/hitch/mcp/internal/json_values.rb +182 -0
- data/app/models/hitch/mcp/internal/local_diagnosis.rb +31 -0
- data/app/models/hitch/mcp/internal/media_type.rb +61 -0
- data/app/models/hitch/mcp/internal/observation.rb +333 -0
- data/app/models/hitch/mcp/internal/registry_runtime.rb +312 -0
- data/app/models/hitch/mcp/internal/result_normalizer.rb +167 -0
- data/app/models/hitch/mcp/internal/sanitized_report.rb +36 -0
- data/app/models/hitch/mcp/internal/schema_contract.rb +173 -0
- data/app/models/hitch/mcp/internal/sdk_adapter/response_normalizer.rb +173 -0
- data/app/models/hitch/mcp/internal/sdk_adapter.rb +222 -0
- data/app/models/hitch/mcp/internal/server_info.rb +49 -0
- data/app/models/hitch/mcp/internal/verified_request.rb +229 -0
- data/app/models/hitch/mcp/internal.rb +11 -0
- data/app/models/hitch/mcp/rate_limit_key.rb +29 -0
- data/app/models/hitch/mcp/registry.rb +70 -0
- data/app/models/hitch/mcp/result.rb +63 -0
- data/app/models/hitch/mcp/tool.rb +148 -0
- data/app/models/hitch/oauth_request_parameters.rb +74 -0
- data/app/views/hitch/authorizations/new.html.erb +57 -0
- data/config/routes.rb +37 -0
- data/db/migrate/20260817000000_create_hitch_tables.rb +77 -0
- data/docs/operator/doctor.md +82 -0
- data/docs/operator/rate_limiting.md +98 -0
- data/docs/public_api/0.2.0.md +322 -0
- data/docs/removing.md +43 -0
- data/lib/generators/hitch/generator_guards.rb +36 -0
- data/lib/generators/hitch/install/install_generator.rb +168 -0
- data/lib/generators/hitch/install/templates/controller.rb.tt +11 -0
- data/lib/generators/hitch/install/templates/initializer.rb +40 -0
- data/lib/generators/hitch/install/templates/registry.rb +6 -0
- data/lib/generators/hitch/tool/templates/tool.rb.tt +54 -0
- data/lib/generators/hitch/tool/templates/tool_test.rb.tt +58 -0
- data/lib/generators/hitch/tool_generator.rb +153 -0
- data/lib/hitch/configuration.rb +386 -0
- data/lib/hitch/doctor.rb +647 -0
- data/lib/hitch/dynamic_registration_rate_limit.rb +75 -0
- data/lib/hitch/engine.rb +154 -0
- data/lib/hitch/mcp/configuration.rb +190 -0
- data/lib/hitch/mcp/protocol.rb +36 -0
- data/lib/hitch/mcp/test_helper.rb +203 -0
- data/lib/hitch/pkce.rb +18 -0
- data/lib/hitch/rack_form_guard.rb +109 -0
- data/lib/hitch/rate_limit_store.rb +47 -0
- data/lib/hitch/resource_uri.rb +71 -0
- data/lib/hitch/version.rb +5 -0
- data/lib/hitch-rails.rb +6 -0
- data/lib/hitch.rb +51 -0
- data/lib/tasks/hitch.rake +197 -0
- 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
|