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,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
module MCP
|
|
5
|
+
class RateLimitKey
|
|
6
|
+
SALT = "hitch/mcp/rate-limit/v1"
|
|
7
|
+
KEY_PREFIX = "hitch:mcp:rate-limit:v1:"
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def call(principal:, client_id:, key_generator: Rails.application.key_generator)
|
|
11
|
+
record_class = principal.class.respond_to?(:base_class) ? principal.class.base_class : principal.class
|
|
12
|
+
invalid_message = "MCP request rate identity is invalid"
|
|
13
|
+
digest = Internal::HmacIdentity.digest(
|
|
14
|
+
salt: SALT,
|
|
15
|
+
components: [
|
|
16
|
+
Internal::HmacIdentity.component(record_class.name, invalid_message:),
|
|
17
|
+
Internal::HmacIdentity.component(principal.id.to_s, invalid_message:),
|
|
18
|
+
Internal::HmacIdentity.component(client_id, invalid_message:)
|
|
19
|
+
],
|
|
20
|
+
key_generator: key_generator,
|
|
21
|
+
unavailable_message: "MCP request rate key is unavailable"
|
|
22
|
+
)
|
|
23
|
+
"#{KEY_PREFIX}#{digest}".freeze
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
private_constant :RateLimitKey
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
module MCP
|
|
5
|
+
# Explicit host allowlist for MCP tools. Subclasses declare named Tool
|
|
6
|
+
# classes; Internal::RegistryRuntime validates the declarations into a
|
|
7
|
+
# persistent snapshot and resolves tools per request.
|
|
8
|
+
class Registry
|
|
9
|
+
Declaration = Data.define(:class_name, :scopes, :source)
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def inherited(subclass)
|
|
13
|
+
super
|
|
14
|
+
subclass.instance_variable_set(:@hitch_mcp_declarations, [].freeze)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def register(tool_class = nil, scopes: nil)
|
|
18
|
+
declaration = Declaration.new(
|
|
19
|
+
class_name: declaration_class_name(tool_class),
|
|
20
|
+
scopes: declaration_scopes(scopes),
|
|
21
|
+
source: declaration_source(tool_class)
|
|
22
|
+
)
|
|
23
|
+
@hitch_mcp_declarations = (declarations + [ declaration ]).freeze
|
|
24
|
+
tool_class
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def declarations
|
|
28
|
+
@hitch_mcp_declarations ||= [].freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def declaration_class_name(tool_class)
|
|
34
|
+
name = tool_class.name if tool_class.respond_to?(:name)
|
|
35
|
+
name.is_a?(String) && !name.empty? ? name.dup.freeze : nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# What the host actually wrote, kept for the boot error that fires
|
|
39
|
+
# when it was not a named class. By then class_name is already nil and
|
|
40
|
+
# only the entry's position is left, which makes a host with a dozen
|
|
41
|
+
# tools count down a list to find their own typo.
|
|
42
|
+
def declaration_source(tool_class)
|
|
43
|
+
case tool_class
|
|
44
|
+
when nil then "no argument"
|
|
45
|
+
when Class then named_or(tool_class, "an anonymous class")
|
|
46
|
+
when Module then named_or(tool_class, "an anonymous module")
|
|
47
|
+
else "#{tool_class.inspect} (a #{tool_class.class})"
|
|
48
|
+
end
|
|
49
|
+
rescue StandardError
|
|
50
|
+
# inspect is host code on an arbitrary object; a registry mistake
|
|
51
|
+
# must not surface as some unrelated exception from this method.
|
|
52
|
+
"a #{tool_class.class}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def named_or(mod, fallback)
|
|
56
|
+
name = mod.name
|
|
57
|
+
name.is_a?(String) && !name.empty? ? name : fallback
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def declaration_scopes(scopes)
|
|
61
|
+
return unless scopes.is_a?(Array)
|
|
62
|
+
|
|
63
|
+
scopes.map { |scope| scope.is_a?(String) ? scope.dup.freeze : nil }.freeze
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private_constant :Declaration
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
module MCP
|
|
5
|
+
# Closed host return value for MCP tool execution. Constructors copy host
|
|
6
|
+
# values so later mutation cannot change the result Hitch validates.
|
|
7
|
+
class Result
|
|
8
|
+
RESULT_MESSAGES = {
|
|
9
|
+
recursive: "MCP Result contains a recursive value",
|
|
10
|
+
key: "MCP Result keys must be Strings",
|
|
11
|
+
duplicate_key: "MCP Result contains a duplicate key",
|
|
12
|
+
non_finite: "MCP Result contains a non-finite number",
|
|
13
|
+
foreign: "MCP Result must contain only JSON values"
|
|
14
|
+
}.freeze
|
|
15
|
+
private_constant :RESULT_MESSAGES
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def text(value)
|
|
19
|
+
new(:text, copy_string(value), nil)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def structured(value, text: nil)
|
|
23
|
+
copied_text = text.nil? ? nil : copy_string(text)
|
|
24
|
+
new(:structured, copy_json(value), copied_text)
|
|
25
|
+
rescue SystemStackError
|
|
26
|
+
raise ArgumentError, "MCP Result nesting is too deep"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def error(public_message)
|
|
30
|
+
new(:error, copy_string(public_message), nil)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def copy_string(value)
|
|
36
|
+
raise ArgumentError, "MCP Result text must be a String" unless value.is_a?(String)
|
|
37
|
+
|
|
38
|
+
value.dup.freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def copy_json(value)
|
|
42
|
+
Internal::JsonValues.copy(
|
|
43
|
+
value,
|
|
44
|
+
keys: :string, symbols: :reject, foreign: :reject, finite: true,
|
|
45
|
+
duplicates: :reject, freeze: true,
|
|
46
|
+
on_invalid: ->(reason, _detail) { raise ArgumentError, RESULT_MESSAGES.fetch(reason) }
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
attr_reader :kind, :value, :text
|
|
52
|
+
|
|
53
|
+
def initialize(kind, value, text)
|
|
54
|
+
@kind = kind
|
|
55
|
+
@value = value
|
|
56
|
+
@text = text
|
|
57
|
+
freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private_class_method :new, :allocate
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
|
|
5
|
+
module Hitch
|
|
6
|
+
module MCP
|
|
7
|
+
# Declarative MCP tool descriptor. Registry admission, listing, and later
|
|
8
|
+
# execution resolve the current named subclass; instances are never needed.
|
|
9
|
+
class Tool
|
|
10
|
+
NOT_SET = Object.new.freeze
|
|
11
|
+
INVALID_DECLARATION = Object.new.freeze
|
|
12
|
+
ARGUMENT_MESSAGES = {
|
|
13
|
+
recursive: "recursive MCP tool arguments",
|
|
14
|
+
key: "MCP tool argument keys must be strings",
|
|
15
|
+
duplicate_key: "duplicate MCP tool argument key",
|
|
16
|
+
non_finite: "MCP tool arguments contain a non-finite number",
|
|
17
|
+
foreign: "MCP tool arguments must contain only JSON values"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def inherited(subclass)
|
|
22
|
+
super
|
|
23
|
+
subclass.instance_variable_set(:@tool_name, nil)
|
|
24
|
+
subclass.instance_variable_set(:@description, nil)
|
|
25
|
+
subclass.instance_variable_set(:@input_schema, nil)
|
|
26
|
+
subclass.instance_variable_set(:@output_schema, nil)
|
|
27
|
+
subclass.instance_variable_set(:@annotations, nil)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def tool_name(value = NOT_SET)
|
|
31
|
+
return @tool_name if value.equal?(NOT_SET)
|
|
32
|
+
|
|
33
|
+
@tool_name = copy_declaration(value)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def description(value = NOT_SET)
|
|
37
|
+
return @description if value.equal?(NOT_SET)
|
|
38
|
+
|
|
39
|
+
@description = copy_declaration(value)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def input_schema(value = NOT_SET, **keywords)
|
|
43
|
+
return @input_schema if value.equal?(NOT_SET) && keywords.empty?
|
|
44
|
+
|
|
45
|
+
@input_schema = copy_declaration(combine_value_and_keywords(value, keywords))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def output_schema(value = NOT_SET, **keywords)
|
|
49
|
+
return @output_schema if value.equal?(NOT_SET) && keywords.empty?
|
|
50
|
+
|
|
51
|
+
@output_schema = copy_declaration(combine_value_and_keywords(value, keywords))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def annotations(value = NOT_SET, **keywords)
|
|
55
|
+
return @annotations if value.equal?(NOT_SET) && keywords.empty?
|
|
56
|
+
|
|
57
|
+
@annotations = copy_declaration(combine_value_and_keywords(value, keywords))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Coarse request-local admission. Host applications must opt each tool
|
|
61
|
+
# in explicitly.
|
|
62
|
+
def available_to?(_context)
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Argument-aware host policy is deny-default. Its return value is not an
|
|
67
|
+
# authority signal: allowing means returning without raising.
|
|
68
|
+
def authorize!(_context, arguments:)
|
|
69
|
+
raise Forbidden
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Host behavior runs only after availability, static scope, SDK schema,
|
|
73
|
+
# and argument-aware policy have all admitted the invocation.
|
|
74
|
+
def perform(_context, arguments:)
|
|
75
|
+
raise "MCP tool perform must be implemented"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# This SDK-facing boundary is final. Registry validation rejects any
|
|
79
|
+
# subclass that replaces it, so every invocation follows the same
|
|
80
|
+
# context extraction, argument normalization, policy, host, and Result
|
|
81
|
+
# normalization order.
|
|
82
|
+
def call(server_context:, **sdk_arguments)
|
|
83
|
+
phase = :context
|
|
84
|
+
invocation = nil
|
|
85
|
+
reporting_tool_name = tool_name
|
|
86
|
+
context = server_context.fetch(:hitch_context)
|
|
87
|
+
phase = :arguments
|
|
88
|
+
arguments = normalize_arguments(sdk_arguments)
|
|
89
|
+
invocation = Internal::Observation.start_invocation(tool_name: reporting_tool_name)
|
|
90
|
+
phase = :authorization
|
|
91
|
+
authorize!(context, arguments:)
|
|
92
|
+
invocation&.argument_policy_allowed!
|
|
93
|
+
phase = :execution
|
|
94
|
+
invocation&.execution_started!
|
|
95
|
+
result = perform(context, arguments:)
|
|
96
|
+
phase = :result
|
|
97
|
+
normalized = Internal::ResultNormalizer.call(
|
|
98
|
+
result:,
|
|
99
|
+
output_schema: output_schema,
|
|
100
|
+
max_bytes: Hitch.configuration.mcp.max_result_bytes
|
|
101
|
+
)
|
|
102
|
+
invocation&.result_normalized!(kind: result.kind)
|
|
103
|
+
normalized
|
|
104
|
+
rescue StandardError, SystemStackError => error
|
|
105
|
+
invocation&.failed!(
|
|
106
|
+
phase:,
|
|
107
|
+
expected_denial: phase == :authorization && error.is_a?(Hitch::MCP::Forbidden)
|
|
108
|
+
)
|
|
109
|
+
Internal::ErrorNormalizer.call(
|
|
110
|
+
error:,
|
|
111
|
+
phase:,
|
|
112
|
+
context:,
|
|
113
|
+
tool_name: reporting_tool_name
|
|
114
|
+
)
|
|
115
|
+
ensure
|
|
116
|
+
invocation&.finish!
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def normalize_arguments(value)
|
|
122
|
+
Internal::JsonValues.copy(
|
|
123
|
+
value,
|
|
124
|
+
keys: :stringify_symbols, symbols: :reject, foreign: :reject,
|
|
125
|
+
finite: true, duplicates: :reject, freeze: true,
|
|
126
|
+
on_invalid: ->(reason, _detail) { raise ArgumentError, ARGUMENT_MESSAGES.fetch(reason) }
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The setter path guards against value and keywords both being absent,
|
|
131
|
+
# so one of the two is always present here.
|
|
132
|
+
def combine_value_and_keywords(value, keywords)
|
|
133
|
+
keywords.empty? ? value : keywords
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def copy_declaration(value)
|
|
137
|
+
Internal::JsonValues.copy(
|
|
138
|
+
value,
|
|
139
|
+
keys: :preserve, foreign: :reject, duplicates: :reject, freeze: true,
|
|
140
|
+
on_invalid: ->(_reason, _detail) { INVALID_DECLARATION }
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private_constant :NOT_SET, :INVALID_DECLARATION, :ARGUMENT_MESSAGES
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
class OauthRequestParameters
|
|
5
|
+
# Deliberately not an ArgumentError: the rescue below converts decoding
|
|
6
|
+
# ArgumentErrors into Invalid, and must never catch its own product.
|
|
7
|
+
class Invalid < StandardError; end
|
|
8
|
+
|
|
9
|
+
FORM_MEDIA_TYPE = "application/x-www-form-urlencoded"
|
|
10
|
+
|
|
11
|
+
def initialize(request, allowed:, form_only: false)
|
|
12
|
+
@request = request
|
|
13
|
+
@allowed = allowed.map(&:to_s).freeze
|
|
14
|
+
@form_only = form_only
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def to_h
|
|
18
|
+
query = query_pairs
|
|
19
|
+
reject_query_security_parameters!(query) if form_only
|
|
20
|
+
pairs = (form_only ? [] : query) + form_pairs
|
|
21
|
+
reject_nested_security_parameters!(pairs)
|
|
22
|
+
|
|
23
|
+
allowed.to_h do |name|
|
|
24
|
+
values = pairs.filter_map { |key, value| value if key == name }
|
|
25
|
+
raise Invalid, "#{name} must not be repeated" if values.length > 1
|
|
26
|
+
raise Invalid, "#{name} must be a string" if values.any? { |value| !value.is_a?(String) }
|
|
27
|
+
raise Invalid, "#{name} must not be blank" if values.one? && values.first.blank?
|
|
28
|
+
|
|
29
|
+
[ name.to_sym, values.first&.dup&.freeze ]
|
|
30
|
+
end.freeze
|
|
31
|
+
rescue ArgumentError
|
|
32
|
+
raise Invalid, "OAuth parameters are not valid form encoding"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
attr_reader :request, :allowed, :form_only
|
|
38
|
+
|
|
39
|
+
def query_pairs
|
|
40
|
+
decode(request.query_string)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def form_pairs
|
|
44
|
+
return [] unless request.post? && request.media_type == FORM_MEDIA_TYPE
|
|
45
|
+
|
|
46
|
+
decode(request.raw_post)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def decode(value)
|
|
50
|
+
return [] if value.blank?
|
|
51
|
+
|
|
52
|
+
URI.decode_www_form(value)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def reject_nested_security_parameters!(pairs)
|
|
56
|
+
pairs.each do |key, _value|
|
|
57
|
+
root = key.to_s.split("[", 2).first
|
|
58
|
+
next unless allowed.include?(root)
|
|
59
|
+
next if key == root
|
|
60
|
+
|
|
61
|
+
raise Invalid, "#{root} must be a scalar string"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def reject_query_security_parameters!(pairs)
|
|
66
|
+
pairs.each do |key, _value|
|
|
67
|
+
root = key.to_s.split("[", 2).first
|
|
68
|
+
next unless allowed.include?(root)
|
|
69
|
+
|
|
70
|
+
raise Invalid, "#{root} must be sent in the form body"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<%# Default consent screen. Host apps override by placing
|
|
2
|
+
app/views/hitch/authorizations/new.html.erb in their own
|
|
3
|
+
tree. The host's layout wraps this template automatically. %>
|
|
4
|
+
|
|
5
|
+
<div class="hitch-rails-consent">
|
|
6
|
+
<h1>Authorize <%= @client_name %></h1>
|
|
7
|
+
<p>
|
|
8
|
+
<strong><%= @client_name %></strong>
|
|
9
|
+
(<%= @redirect_host %>)
|
|
10
|
+
is requesting access to your <%= @brand_name %> account.
|
|
11
|
+
</p>
|
|
12
|
+
|
|
13
|
+
<% if @localhost_only_client %>
|
|
14
|
+
<p class="hitch-rails-consent__warning">
|
|
15
|
+
<strong>This application runs on your own computer.</strong>
|
|
16
|
+
It will receive access at <code><%= @redirect_host %></code>, so
|
|
17
|
+
approve it only if you started it yourself and recognise it.
|
|
18
|
+
</p>
|
|
19
|
+
<% end %>
|
|
20
|
+
|
|
21
|
+
<% if @scopes.present? %>
|
|
22
|
+
<div class="hitch-rails-consent__scopes">
|
|
23
|
+
<p>It will be granted the following access:</p>
|
|
24
|
+
<ul>
|
|
25
|
+
<% @scopes.split(/\s+/).each do |scope| %>
|
|
26
|
+
<li><code><%= scope %></code></li>
|
|
27
|
+
<% end %>
|
|
28
|
+
</ul>
|
|
29
|
+
</div>
|
|
30
|
+
<% end %>
|
|
31
|
+
|
|
32
|
+
<% if @resource.present? %>
|
|
33
|
+
<p class="hitch-rails-consent__resource">
|
|
34
|
+
Resource: <code><%= @resource %></code>
|
|
35
|
+
</p>
|
|
36
|
+
<% end %>
|
|
37
|
+
|
|
38
|
+
<%# turbo: false forces a real browser navigation. The controller
|
|
39
|
+
redirects to the client's redirect_uri (a cross-origin host, e.g.
|
|
40
|
+
https://grok.com/...); Turbo Drive submits via fetch and CANNOT
|
|
41
|
+
follow a cross-origin 302, so the redirect is swallowed and the
|
|
42
|
+
buttons appear to do nothing. A full-page submit lets the browser
|
|
43
|
+
follow the redirect and deliver the response to the client. %>
|
|
44
|
+
<%= form_with url: oauth_authorize_path, method: :post, data: { turbo: false } do |f| %>
|
|
45
|
+
<% @oauth_params.each do |key, value| %>
|
|
46
|
+
<% next if value.blank? %>
|
|
47
|
+
<%= hidden_field_tag key, value %>
|
|
48
|
+
<% end %>
|
|
49
|
+
|
|
50
|
+
<button type="submit" class="hitch-rails-consent__approve">
|
|
51
|
+
Approve
|
|
52
|
+
</button>
|
|
53
|
+
<button type="submit" name="decision" value="deny" class="hitch-rails-consent__deny">
|
|
54
|
+
Deny
|
|
55
|
+
</button>
|
|
56
|
+
<% end %>
|
|
57
|
+
</div>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Hitch::Engine.routes.draw do
|
|
4
|
+
# OAuth dance
|
|
5
|
+
get "oauth/authorize", to: "authorizations#new", as: :oauth_authorize
|
|
6
|
+
post "oauth/authorize", to: "authorizations#create"
|
|
7
|
+
post "oauth/token", to: "tokens#create", as: :oauth_token
|
|
8
|
+
post "oauth/register", to: "registrations#create", as: :oauth_register
|
|
9
|
+
post "oauth/revoke", to: "revocations#create", as: :oauth_revoke
|
|
10
|
+
|
|
11
|
+
# Route only real engine endpoints to the dedicated preflight validator.
|
|
12
|
+
match "oauth/authorize", to: "preflights#show", via: :options, defaults: { target_methods: "GET,POST" }
|
|
13
|
+
match "oauth/token", to: "preflights#show", via: :options, defaults: { target_methods: "POST" }
|
|
14
|
+
match "oauth/register", to: "preflights#show", via: :options, defaults: { target_methods: "POST" }
|
|
15
|
+
match "oauth/revoke", to: "preflights#show", via: :options, defaults: { target_methods: "POST" }
|
|
16
|
+
|
|
17
|
+
# Discovery (RFC 8414 + RFC 9728)
|
|
18
|
+
get ".well-known/oauth-authorization-server", to: "metadata#show", as: :oauth_authorization_server_metadata
|
|
19
|
+
get ".well-known/oauth-protected-resource", to: "metadata#resource", as: :oauth_protected_resource_metadata
|
|
20
|
+
|
|
21
|
+
# RFC 9728 §3.1: a resource served at a path (e.g. /mcp) publishes its
|
|
22
|
+
# metadata at the PATH-AWARE well-known URI. Strict clients (Grok)
|
|
23
|
+
# request /.well-known/oauth-protected-resource/<path> FIRST, then fall
|
|
24
|
+
# back to the bare variant — without this route that first probe 404s.
|
|
25
|
+
# The gem serves a single configured resource, so the captured path is
|
|
26
|
+
# ignored and the same document is returned. `format: false` keeps a
|
|
27
|
+
# dotted resource path from being parsed as a response format. Declared
|
|
28
|
+
# after the bare route (glob-last).
|
|
29
|
+
get ".well-known/oauth-protected-resource/*resource_path", to: "metadata#resource", format: false
|
|
30
|
+
|
|
31
|
+
match ".well-known/oauth-authorization-server", to: "preflights#show", via: :options,
|
|
32
|
+
defaults: { target_methods: "GET" }
|
|
33
|
+
match ".well-known/oauth-protected-resource", to: "preflights#show", via: :options,
|
|
34
|
+
defaults: { target_methods: "GET" }
|
|
35
|
+
match ".well-known/oauth-protected-resource/*resource_path", to: "preflights#show", via: :options,
|
|
36
|
+
defaults: { target_methods: "GET" }, format: false
|
|
37
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateHitchTables < ActiveRecord::Migration[7.2]
|
|
4
|
+
def change
|
|
5
|
+
create_table :hitch_access_tokens do |t|
|
|
6
|
+
# Polymorphic principals may use integer, UUID, ULID, or other
|
|
7
|
+
# string-shaped primary keys. A numeric foreign-key column silently
|
|
8
|
+
# truncates those values on SQLite and rejects them on PostgreSQL, so
|
|
9
|
+
# keep the shared representation lossless and let each principal model
|
|
10
|
+
# cast its own primary key when Rails resolves the association.
|
|
11
|
+
t.references :principal, polymorphic: true, type: :string, null: false, index: true
|
|
12
|
+
|
|
13
|
+
t.string :client_id, null: false
|
|
14
|
+
t.string :client_name
|
|
15
|
+
|
|
16
|
+
# The raw authorization code is returned to the client once, in the
|
|
17
|
+
# OAuth redirect; only its SHA256 digest is at rest — the same
|
|
18
|
+
# posture as bearer tokens.
|
|
19
|
+
t.string :authorization_code_digest,
|
|
20
|
+
index: { unique: true, where: "authorization_code_digest IS NOT NULL" }
|
|
21
|
+
t.datetime :code_expires_at
|
|
22
|
+
t.string :redirect_uri
|
|
23
|
+
|
|
24
|
+
t.string :code_challenge, null: false
|
|
25
|
+
t.string :code_challenge_method, null: false, default: "S256"
|
|
26
|
+
|
|
27
|
+
t.string :token_digest, index: { unique: true, where: "token_digest IS NOT NULL" }
|
|
28
|
+
t.datetime :expires_at
|
|
29
|
+
t.datetime :revoked_at
|
|
30
|
+
|
|
31
|
+
t.string :resource_uri
|
|
32
|
+
t.string :scopes, null: false, default: "mcp"
|
|
33
|
+
|
|
34
|
+
t.timestamps
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
create_table :hitch_clients do |t|
|
|
38
|
+
t.string :client_id, null: false, index: { unique: true }
|
|
39
|
+
t.string :client_name, null: false
|
|
40
|
+
|
|
41
|
+
# OpenID Connect DCR 1.0 §2. Nullable and deliberately NOT defaulted
|
|
42
|
+
# to "web": NULL means "did not declare", which is the fact worth
|
|
43
|
+
# recording — a future decision to gate loopback redirects on
|
|
44
|
+
# application_type must be able to see who actually said "native".
|
|
45
|
+
t.string :application_type
|
|
46
|
+
|
|
47
|
+
t.string :token_endpoint_auth_method, null: false, default: "none"
|
|
48
|
+
t.string :client_secret_digest
|
|
49
|
+
t.datetime :client_secret_issued_at
|
|
50
|
+
t.datetime :client_secret_rotated_at
|
|
51
|
+
|
|
52
|
+
t.timestamps
|
|
53
|
+
|
|
54
|
+
t.check_constraint "token_endpoint_auth_method IN ('none', 'client_secret_basic')",
|
|
55
|
+
name: "hitch_clients_auth_method_check"
|
|
56
|
+
t.check_constraint <<~SQL.squish, name: "hitch_clients_secret_consistency_check"
|
|
57
|
+
(token_endpoint_auth_method = 'none' AND client_secret_digest IS NULL AND client_secret_issued_at IS NULL AND client_secret_rotated_at IS NULL)
|
|
58
|
+
OR
|
|
59
|
+
(token_endpoint_auth_method = 'client_secret_basic' AND client_secret_digest IS NOT NULL AND client_secret_issued_at IS NOT NULL)
|
|
60
|
+
SQL
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
create_table :hitch_client_redirect_uris do |t|
|
|
64
|
+
t.references :hitch_client,
|
|
65
|
+
null: false,
|
|
66
|
+
index: false,
|
|
67
|
+
foreign_key: { to_table: :hitch_clients, on_delete: :cascade }
|
|
68
|
+
t.string :uri, null: false
|
|
69
|
+
|
|
70
|
+
t.timestamps
|
|
71
|
+
|
|
72
|
+
t.index [ :hitch_client_id, :uri ],
|
|
73
|
+
unique: true,
|
|
74
|
+
name: "index_hitch_client_redirect_uris_on_client_and_uri"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Hitch doctor
|
|
2
|
+
|
|
3
|
+
`hitch:doctor` is a read-only installation diagnostic for the current Rails
|
|
4
|
+
environment. Run it after installation, before a deploy, and whenever routing,
|
|
5
|
+
discovery, registry loading, or request admission behaves differently than the
|
|
6
|
+
host expects:
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
bin/rails hitch:doctor
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The default human output has one stable row per check, plus an indented
|
|
13
|
+
`->` line naming the fix for any check that did not pass. Machine consumers
|
|
14
|
+
select the versioned JSON document explicitly:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
HITCH_DOCTOR_FORMAT=json bin/rails hitch:doctor
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The only accepted formats are `human` and `json`. The process exits zero when
|
|
21
|
+
all findings are `pass`, `skip`, or `warn`; any `fail` finding exits one after
|
|
22
|
+
the complete report is printed. Warnings identify a supported but non-golden
|
|
23
|
+
posture, such as an unshared cache store in development/test, an empty
|
|
24
|
+
explicit Registry, or plain-HTTP browser origins in production.
|
|
25
|
+
|
|
26
|
+
## Stable check categories
|
|
27
|
+
|
|
28
|
+
The JSON schema identifier is `hitch.doctor.v1`. Its `checks` array always has
|
|
29
|
+
these IDs in this order:
|
|
30
|
+
|
|
31
|
+
1. `versions` — loaded Hitch, Ruby, Rails, and MCP versions are inside the
|
|
32
|
+
packaged support window.
|
|
33
|
+
2. `configuration` — the current OAuth configuration is valid and any enabled
|
|
34
|
+
MCP runtime has all required settings. Production DCR also requires its
|
|
35
|
+
resolved rate store (default: `config.cache_store`) to count across
|
|
36
|
+
processes.
|
|
37
|
+
3. `resource_discovery` — internal requests to both discovery documents agree
|
|
38
|
+
with the canonical resource URI and issuer. No external network request is
|
|
39
|
+
made.
|
|
40
|
+
4. `route_order` — exactly one modern MCP endpoint owns the canonical path,
|
|
41
|
+
admits its full method contract, precedes one root Hitch engine mount, and is
|
|
42
|
+
not shadowed. Auth-only mode skips this MCP-only check.
|
|
43
|
+
5. `migrations` — every packaged Hitch migration has run and every required
|
|
44
|
+
table exists.
|
|
45
|
+
6. `registry` — the configured Registry resolves and validates atomically. An
|
|
46
|
+
empty Registry warns because no tool is exposed; auth-only mode skips it.
|
|
47
|
+
7. `hosts` — Rails host authorization accepts the canonical resource host and
|
|
48
|
+
every additional exact Hitch host.
|
|
49
|
+
8. `origins` — browser CORS is deny-default or uses exact configured origins.
|
|
50
|
+
Plain-HTTP browser origins warn in production.
|
|
51
|
+
9. `rate_limit_store` — one isolated diagnostic key increments twice against
|
|
52
|
+
the configured admission store, returns `1` then `2`, and is removed. It
|
|
53
|
+
never uses Hitch's application quota-key namespace. A store that cannot
|
|
54
|
+
count, or one that cannot count across processes, fails in production and
|
|
55
|
+
warns elsewhere; auth-only mode skips it.
|
|
56
|
+
|
|
57
|
+
Every check names something the host can act on. Gem-self-diagnosis (packaged
|
|
58
|
+
file integrity) lives in this repository's CI, not here. The `versions` bounds
|
|
59
|
+
are read from the loaded gemspec, so the report can never disagree with the
|
|
60
|
+
gem's declared support window.
|
|
61
|
+
|
|
62
|
+
Each check has `status`, stable `code`, human `summary`, and bounded structural
|
|
63
|
+
`details`. Exception messages, credentials, bearer values, request bodies,
|
|
64
|
+
store credentials, and diagnostic keys are never reported. The admission-store
|
|
65
|
+
check reports the store class, whether it is shared across processes, and the
|
|
66
|
+
two probe counts — as integers, nil, or a class name, never message text.
|
|
67
|
+
|
|
68
|
+
## No repair mode
|
|
69
|
+
|
|
70
|
+
Doctor does not edit configuration, routes, Registry declarations, migrations,
|
|
71
|
+
or application data. Its internal discovery requests are GETs against the
|
|
72
|
+
loaded Rack application. The store probes are the only writes: the
|
|
73
|
+
`rate_limit_store` check increments a random `hitch:doctor:v1:*` key twice on
|
|
74
|
+
the configured cache store with a five-second expiry, asserts the counts come
|
|
75
|
+
back `1` then `2`, and deletes the key; when production DCR is enabled, the
|
|
76
|
+
`configuration` check increments one such key on the registration store,
|
|
77
|
+
requires an integer count, and deletes it. That namespace is distinct from
|
|
78
|
+
Hitch's HMAC rate-limit keys.
|
|
79
|
+
|
|
80
|
+
Fix the named host artifact and rerun the command. Do not parse the human prose
|
|
81
|
+
for automation; parse `hitch.doctor.v1` JSON by check `id`, `status`, and
|
|
82
|
+
`code`.
|