hitch-rails 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +103 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +460 -0
  5. data/SECURITY.md +118 -0
  6. data/app/controllers/concerns/hitch/cors_support.rb +97 -0
  7. data/app/controllers/concerns/hitch/host_validation.rb +51 -0
  8. data/app/controllers/concerns/hitch/issuer_url.rb +26 -0
  9. data/app/controllers/concerns/hitch/mcp/endpoint.rb +355 -0
  10. data/app/controllers/concerns/hitch/oauth_form_admission.rb +83 -0
  11. data/app/controllers/concerns/hitch/oauth_parameter_validation.rb +26 -0
  12. data/app/controllers/concerns/hitch/registration_admission.rb +115 -0
  13. data/app/controllers/concerns/hitch/request_admission.rb +46 -0
  14. data/app/controllers/concerns/hitch/uri_validation.rb +116 -0
  15. data/app/controllers/hitch/application_controller.rb +59 -0
  16. data/app/controllers/hitch/authorizations_controller.rb +152 -0
  17. data/app/controllers/hitch/metadata_controller.rb +114 -0
  18. data/app/controllers/hitch/preflights_controller.rb +14 -0
  19. data/app/controllers/hitch/public_endpoint_controller.rb +36 -0
  20. data/app/controllers/hitch/registrations_controller.rb +135 -0
  21. data/app/controllers/hitch/revocations_controller.rb +31 -0
  22. data/app/controllers/hitch/tokens_controller.rb +89 -0
  23. data/app/models/hitch/access_token.rb +267 -0
  24. data/app/models/hitch/application_record.rb +7 -0
  25. data/app/models/hitch/authorization_request.rb +252 -0
  26. data/app/models/hitch/client/credentials.rb +30 -0
  27. data/app/models/hitch/client.rb +237 -0
  28. data/app/models/hitch/client_authentication.rb +80 -0
  29. data/app/models/hitch/client_id_metadata/cache.rb +69 -0
  30. data/app/models/hitch/client_id_metadata/fetcher.rb +277 -0
  31. data/app/models/hitch/client_id_metadata/throttle.rb +119 -0
  32. data/app/models/hitch/client_id_metadata.rb +316 -0
  33. data/app/models/hitch/client_redirect_uri.rb +14 -0
  34. data/app/models/hitch/mcp/context.rb +91 -0
  35. data/app/models/hitch/mcp/forbidden.rb +10 -0
  36. data/app/models/hitch/mcp/internal/bearer_challenge.rb +51 -0
  37. data/app/models/hitch/mcp/internal/cors_policy.rb +53 -0
  38. data/app/models/hitch/mcp/internal/endpoint_error_reporter.rb +40 -0
  39. data/app/models/hitch/mcp/internal/error_normalizer.rb +74 -0
  40. data/app/models/hitch/mcp/internal/header_field.rb +31 -0
  41. data/app/models/hitch/mcp/internal/hmac_identity.rb +37 -0
  42. data/app/models/hitch/mcp/internal/host_authority.rb +51 -0
  43. data/app/models/hitch/mcp/internal/json_values.rb +182 -0
  44. data/app/models/hitch/mcp/internal/local_diagnosis.rb +31 -0
  45. data/app/models/hitch/mcp/internal/media_type.rb +61 -0
  46. data/app/models/hitch/mcp/internal/observation.rb +333 -0
  47. data/app/models/hitch/mcp/internal/registry_runtime.rb +312 -0
  48. data/app/models/hitch/mcp/internal/result_normalizer.rb +167 -0
  49. data/app/models/hitch/mcp/internal/sanitized_report.rb +36 -0
  50. data/app/models/hitch/mcp/internal/schema_contract.rb +173 -0
  51. data/app/models/hitch/mcp/internal/sdk_adapter/response_normalizer.rb +173 -0
  52. data/app/models/hitch/mcp/internal/sdk_adapter.rb +222 -0
  53. data/app/models/hitch/mcp/internal/server_info.rb +49 -0
  54. data/app/models/hitch/mcp/internal/verified_request.rb +229 -0
  55. data/app/models/hitch/mcp/internal.rb +11 -0
  56. data/app/models/hitch/mcp/rate_limit_key.rb +29 -0
  57. data/app/models/hitch/mcp/registry.rb +70 -0
  58. data/app/models/hitch/mcp/result.rb +63 -0
  59. data/app/models/hitch/mcp/tool.rb +148 -0
  60. data/app/models/hitch/oauth_request_parameters.rb +74 -0
  61. data/app/views/hitch/authorizations/new.html.erb +57 -0
  62. data/config/routes.rb +37 -0
  63. data/db/migrate/20260817000000_create_hitch_tables.rb +77 -0
  64. data/docs/operator/doctor.md +82 -0
  65. data/docs/operator/rate_limiting.md +98 -0
  66. data/docs/public_api/0.2.0.md +322 -0
  67. data/docs/removing.md +43 -0
  68. data/lib/generators/hitch/generator_guards.rb +36 -0
  69. data/lib/generators/hitch/install/install_generator.rb +168 -0
  70. data/lib/generators/hitch/install/templates/controller.rb.tt +11 -0
  71. data/lib/generators/hitch/install/templates/initializer.rb +40 -0
  72. data/lib/generators/hitch/install/templates/registry.rb +6 -0
  73. data/lib/generators/hitch/tool/templates/tool.rb.tt +54 -0
  74. data/lib/generators/hitch/tool/templates/tool_test.rb.tt +58 -0
  75. data/lib/generators/hitch/tool_generator.rb +153 -0
  76. data/lib/hitch/configuration.rb +386 -0
  77. data/lib/hitch/doctor.rb +647 -0
  78. data/lib/hitch/dynamic_registration_rate_limit.rb +75 -0
  79. data/lib/hitch/engine.rb +154 -0
  80. data/lib/hitch/mcp/configuration.rb +190 -0
  81. data/lib/hitch/mcp/protocol.rb +36 -0
  82. data/lib/hitch/mcp/test_helper.rb +203 -0
  83. data/lib/hitch/pkce.rb +18 -0
  84. data/lib/hitch/rack_form_guard.rb +109 -0
  85. data/lib/hitch/rate_limit_store.rb +47 -0
  86. data/lib/hitch/resource_uri.rb +71 -0
  87. data/lib/hitch/version.rb +5 -0
  88. data/lib/hitch-rails.rb +6 -0
  89. data/lib/hitch.rb +51 -0
  90. data/lib/tasks/hitch.rake +197 -0
  91. metadata +230 -0
@@ -0,0 +1,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`.