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