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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ <% @controller_modules.each_with_index do |namespace, index| -%>
4
+ <%= " " * index %>module <%= namespace %>
5
+ <% end -%>
6
+ <%= " " * @controller_modules.length %>class <%= @controller_leaf %> < ActionController::API
7
+ <%= " " * (@controller_modules.length + 1) %>include Hitch::MCP::Endpoint
8
+ <%= " " * @controller_modules.length %>end
9
+ <% @controller_modules.length.times.reverse_each do |index| -%>
10
+ <%= " " * index %>end
11
+ <% end -%>
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ # hitch-rails configuration. These are the knobs every host must set; the
4
+ # full reference (proxy hosts, scopes, token lifetimes, byte caps, rate
5
+ # limits) lives at github.com/tylerklose/hitch-rails.
6
+ Hitch.configure do |config|
7
+ # This MCP server's canonical resource URI for RFC 8707 audience binding.
8
+ # MUST match what MCP clients send in the `resource` parameter.
9
+ config.resource_uri = "https://your-app.example.com/mcp"
10
+
11
+ # Display name shown on the OAuth consent screen.
12
+ config.brand_name = "Your App"
13
+
14
+ # Where to send a signed-out visitor who lands on the consent screen.
15
+ # Without it the browser OAuth flow answers a bare 401 and no MCP client
16
+ # can complete sign-in. "/session/new" is what Rails 8's own
17
+ # `bin/rails generate authentication` creates; Devise uses
18
+ # "/users/sign_in".
19
+ config.login_path = "/session/new"
20
+
21
+ # Exact browser origins allowed to call the endpoint, including scheme and
22
+ # non-default port. Denied by default; development and test also accept
23
+ # loopback origins.
24
+ config.allowed_origins = []
25
+
26
+ # Client ID Metadata Documents — how MCP 2026-07-28 clients register.
27
+ # Requires DIRECT outbound https on port 443 (Hitch deliberately ignores
28
+ # http_proxy); set false if this tier has no direct egress. Verify with:
29
+ # bin/rails 'hitch:cimd:check[https://some-client.example/client.json]'
30
+ config.client_id_metadata_enabled = true
31
+
32
+ # Dynamic Client Registration is unauthenticated and deprecated by the MCP
33
+ # specification, so new installations do not expose it.
34
+ config.dynamic_client_registration_enabled = false
35
+
36
+ # The authenticated /mcp endpoint. Tools stay deny-default until they are
37
+ # reviewed and registered in app/tools/mcp_tool_registry.rb.
38
+ config.mcp.enabled = true
39
+ config.mcp.registry = "McpToolRegistry"
40
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Tools are listed and callable only when registered here. Generate one with
4
+ # `bin/rails generate hitch:tool NAME`.
5
+ class McpToolRegistry < Hitch::MCP::Registry
6
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ <% @class_modules.each_with_index do |namespace, index| -%>
4
+ <%= " " * index %>module <%= namespace %>
5
+ <% end -%>
6
+ <%= " " * @class_modules.length %>class <%= @class_leaf %> < Hitch::MCP::Tool
7
+ <%= " " * (@class_modules.length + 1) %>tool_name "<%= @tool_name %>"
8
+ <%= " " * (@class_modules.length + 1) %>description "TODO: describe <%= @tool_name %>"
9
+ <%= " " * (@class_modules.length + 1) %>input_schema(
10
+ <%= " " * (@class_modules.length + 2) %>type: "object",
11
+ <%= " " * (@class_modules.length + 2) %>properties: {},
12
+ <%= " " * (@class_modules.length + 2) %>additionalProperties: false
13
+ <%= " " * (@class_modules.length + 1) %>)
14
+ <% if deny_default? -%>
15
+ <%= " " * (@class_modules.length + 1) %>annotations read_only_hint: false,
16
+ <%= " " * (@class_modules.length + 2) %>destructive_hint: true,
17
+ <%= " " * (@class_modules.length + 2) %>idempotent_hint: false,
18
+ <%= " " * (@class_modules.length + 2) %>open_world_hint: true
19
+
20
+ <%= " " * (@class_modules.length + 1) %>def self.available_to?(_context)
21
+ <%= " " * (@class_modules.length + 2) %>false
22
+ <%= " " * (@class_modules.length + 1) %>end
23
+
24
+ <%= " " * (@class_modules.length + 1) %>def self.authorize!(_context, arguments:)
25
+ <%= " " * (@class_modules.length + 2) %>raise Hitch::MCP::Forbidden
26
+ <%= " " * (@class_modules.length + 1) %>end
27
+
28
+ <%= " " * (@class_modules.length + 1) %>def self.perform(_context, arguments:)
29
+ <%= " " * (@class_modules.length + 2) %>raise "Implement <%= @class_name %>.perform before making the tool available"
30
+ <%= " " * (@class_modules.length + 1) %>end
31
+ <% else -%>
32
+ <%= " " * (@class_modules.length + 1) %>annotations read_only_hint: true,
33
+ <%= " " * (@class_modules.length + 2) %>destructive_hint: false,
34
+ <%= " " * (@class_modules.length + 2) %>idempotent_hint: true,
35
+ <%= " " * (@class_modules.length + 2) %>open_world_hint: false
36
+
37
+ <%= " " * (@class_modules.length + 1) %>def self.available_to?(_context)
38
+ <%= " " * (@class_modules.length + 2) %>true
39
+ <%= " " * (@class_modules.length + 1) %>end
40
+
41
+ <%= " " * (@class_modules.length + 1) %>def self.authorize!(_context, arguments:)
42
+ <%= " " * (@class_modules.length + 2) %># Returning without raising ALLOWS the call. Put your policy here,
43
+ <%= " " * (@class_modules.length + 2) %># naming the context argument to use it:
44
+ <%= " " * (@class_modules.length + 2) %># raise Hitch::MCP::Forbidden unless context.principal.admin?
45
+ <%= " " * (@class_modules.length + 1) %>end
46
+
47
+ <%= " " * (@class_modules.length + 1) %>def self.perform(_context, arguments:)
48
+ <%= " " * (@class_modules.length + 2) %>Hitch::MCP::Result.text("<%= @tool_name %> is wired up; implement perform to do real work")
49
+ <%= " " * (@class_modules.length + 1) %>end
50
+ <% end -%>
51
+ <%= " " * @class_modules.length %>end
52
+ <% @class_modules.length.times.reverse_each do |index| -%>
53
+ <%= " " * index %>end
54
+ <% end -%>
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "hitch/mcp/test_helper"
5
+
6
+ class <%= @test_class_name %> < ActionDispatch::IntegrationTest
7
+ # post_mcp drives your mounted /mcp endpoint over real HTTP — see
8
+ # Hitch::MCP::TestHelper.
9
+ include Hitch::MCP::TestHelper
10
+
11
+ setup do
12
+ # post_mcp needs a real bearer token. Point this at however your tests
13
+ # get a signed-in user — a fixture, a factory, or a record created here.
14
+ @token = mint_mcp_token(principal: User.first!)
15
+ end
16
+ <% if deny_default? -%>
17
+
18
+ test "stays hidden until the host implements and opens it" do
19
+ post_mcp(method: "tools/list", token: @token)
20
+
21
+ assert_response :success
22
+ tools = JSON.parse(response.body).dig("result", "tools")
23
+ refute_includes tools.map { |tool| tool.fetch("name") }, "<%= @tool_name %>"
24
+ end
25
+
26
+ test "calls are refused while unavailable" do
27
+ post_mcp(
28
+ method: "tools/call",
29
+ token: @token,
30
+ params: { name: "<%= @tool_name %>", arguments: {} }
31
+ )
32
+
33
+ assert_response :success
34
+ assert_equal(-32602, JSON.parse(response.body).dig("error", "code"))
35
+ end
36
+ <% else -%>
37
+
38
+ test "is listed for an authorized principal" do
39
+ post_mcp(method: "tools/list", token: @token)
40
+
41
+ assert_response :success
42
+ tools = JSON.parse(response.body).dig("result", "tools")
43
+ assert_includes tools.map { |tool| tool.fetch("name") }, "<%= @tool_name %>"
44
+ end
45
+
46
+ test "responds when called" do
47
+ post_mcp(
48
+ method: "tools/call",
49
+ token: @token,
50
+ params: { name: "<%= @tool_name %>", arguments: {} }
51
+ )
52
+
53
+ assert_response :success
54
+ result = JSON.parse(response.body).fetch("result")
55
+ assert_includes result.dig("content", 0, "text"), "<%= @tool_name %>"
56
+ end
57
+ <% end -%>
58
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections"
4
+ require "rails/generators"
5
+ require_relative "generator_guards"
6
+
7
+ module Hitch
8
+ module Generators
9
+ # Generates a working, registered MCP tool plus an integration test that
10
+ # proves it responds over real HTTP. --deny-default emits a hardened
11
+ # variant instead: unavailable, denying, and unimplemented until the
12
+ # host fills it in.
13
+ #
14
+ # Usage:
15
+ # bin/rails generate hitch:tool NAME
16
+ # bin/rails destroy hitch:tool NAME
17
+ class ToolGenerator < ::Rails::Generators::Base
18
+ include Hitch::Generators::GeneratorGuards
19
+
20
+ namespace "hitch:tool"
21
+
22
+ source_root File.expand_path("tool/templates", __dir__)
23
+
24
+ argument :name, type: :string, required: true,
25
+ desc: "Tool name, optionally nested with / or ::"
26
+ class_option :namespace,
27
+ type: :string,
28
+ default: "McpTools",
29
+ desc: "Root Ruby namespace for the generated tool"
30
+ class_option :deny_default,
31
+ type: :boolean,
32
+ default: false,
33
+ desc: "Generate the hardened variant: unavailable and denying until implemented"
34
+
35
+ NAME_SEGMENT_PATTERN =
36
+ /\A(?:[a-z][a-z0-9]*(?:[_-][a-z0-9]+)*|[A-Z][A-Za-z0-9]*)\z/
37
+ CANONICAL_SEGMENT_PATTERN = /\A[a-z][a-z0-9]*(?:_[a-z0-9]+)*\z/
38
+ NAMESPACE_PATTERN = /\A[A-Z][A-Za-z0-9]*(?:::[A-Z][A-Za-z0-9]*)*\z/
39
+
40
+ def self.exit_on_failure?
41
+ true
42
+ end
43
+
44
+ def generate_or_revoke
45
+ prepare_identity!
46
+ behavior == :revoke ? preflight_revoke! : preflight!
47
+
48
+ # Behavior-aware Thor actions: create/inject in invoke, remove in
49
+ # revoke.
50
+ template "tool.rb.tt", @tool_path
51
+ template "tool_test.rb.tt", @test_path
52
+ inject_into_class REGISTRY_PATH, "McpToolRegistry", " #{@registration_line}\n"
53
+
54
+ print_next_steps unless behavior == :revoke
55
+ end
56
+
57
+ private
58
+
59
+ # inject_into_class revokes by exact bytes; a reformatted registration
60
+ # line would survive while the tool file disappears, stranding a
61
+ # NameError at the next boot. Refuse the whole rollback instead.
62
+ def preflight_revoke!
63
+ registry = destination_file?(REGISTRY_PATH) ? File.binread(destination_path(REGISTRY_PATH)) : ""
64
+ return unless registry.match?(/^\s*register\s+#{Regexp.escape(@class_name)}\b/)
65
+ return if registry.include?(@registration_line)
66
+
67
+ refuse!("rollback", [
68
+ "#{REGISTRY_PATH} contains an edited registration for #{@class_name}; " \
69
+ "remove that line by hand, then rerun bin/rails destroy hitch:tool"
70
+ ])
71
+ end
72
+
73
+ def preflight!
74
+ errors = []
75
+ [ @tool_path, @test_path ].each do |path|
76
+ errors << "file collision: #{path}" if destination_file?(path)
77
+ end
78
+ errors << "constant collision: #{@class_name}" if constant_collision?(@class_name)
79
+ errors << "constant collision: #{@test_class_name}" if constant_collision?(@test_class_name)
80
+ errors << "#{REGISTRY_PATH} is missing; run `bin/rails generate hitch:install` first" unless
81
+ destination_file?(REGISTRY_PATH)
82
+ refuse!("generation", errors) if errors.any?
83
+ end
84
+
85
+ def prepare_identity!
86
+ namespace = options.fetch("namespace")
87
+ canonical_segments = normalize_name(name)
88
+ errors = []
89
+ errors << "name must contain valid snake-case, kebab-case, or constant segments" unless canonical_segments
90
+ errors << "namespace must be a bounded constant path" unless valid_namespace?(namespace)
91
+ refuse!(operation, errors) if errors.any?
92
+
93
+ @tool_name = canonical_segments.join(".")
94
+ refuse!(operation, [ "normalized MCP tool name must be 1 to 64 characters" ]) unless
95
+ @tool_name.length.between?(1, 64)
96
+
97
+ nested_constants = canonical_segments.map(&:camelize)
98
+ @class_name = ([ namespace ] + nested_constants).join("::")
99
+ @test_class_name = "#{@class_name}Test"
100
+ @class_modules = @class_name.split("::")[0...-1]
101
+ @class_leaf = @class_name.split("::").last
102
+ @tool_path = "app/tools/#{@class_name.underscore}.rb"
103
+ @test_path = "test/integration/#{@class_name.underscore}_test.rb"
104
+ @registration_line = %(register #{@class_name}, scopes: [ "mcp" ])
105
+ end
106
+
107
+ def normalize_name(value)
108
+ return unless value.is_a?(String) && value.bytesize.between?(1, 256)
109
+
110
+ source = value.gsub("::", "/")
111
+ return if source.start_with?("/") || source.end_with?("/") || source.include?("//")
112
+
113
+ segments = source.split("/")
114
+ normalized = segments.filter_map do |segment|
115
+ next unless NAME_SEGMENT_PATTERN.match?(segment)
116
+
117
+ candidate = segment.match?(/\A[A-Z]/) ? segment.underscore : segment.tr("-", "_")
118
+ candidate if CANONICAL_SEGMENT_PATTERN.match?(candidate)
119
+ end
120
+ normalized if normalized.length == segments.length
121
+ end
122
+
123
+ def valid_namespace?(value)
124
+ return false unless value.is_a?(String) && value.bytesize.between?(1, 255)
125
+ return false unless NAMESPACE_PATTERN.match?(value)
126
+
127
+ segments = value.split("::")
128
+ segments.length <= 8 && segments.all? { |segment| segment.bytesize <= 64 }
129
+ end
130
+
131
+ def deny_default?
132
+ options.fetch("deny_default")
133
+ end
134
+
135
+ def operation
136
+ behavior == :revoke ? "rollback" : "generation"
137
+ end
138
+
139
+ def refusal_subject = "Hitch tool"
140
+
141
+ def print_next_steps
142
+ say ""
143
+ if deny_default?
144
+ say "Generated and registered deny-default Hitch MCP tool #{@class_name}.", :green
145
+ say "It stays unavailable until you implement it."
146
+ else
147
+ say "Generated and registered Hitch MCP tool #{@class_name}.", :green
148
+ say "Verify it responds: bin/rails test #{@test_path}"
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end