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,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+
6
+ module Hitch
7
+ module MCP
8
+ module Internal
9
+ # HMAC-SHA256 digest over canonical JSON identity components, shared by
10
+ # rate-limit keying and observation identity. Each caller keeps its own
11
+ # salt, component layout, and output shape.
12
+ class HmacIdentity
13
+ MAX_COMPONENT_BYTES = 2_048
14
+
15
+ class << self
16
+ def digest(salt:, components:, key_generator:, unavailable_message:)
17
+ secret = key_generator.generate_key(salt, 32)
18
+ unless secret.is_a?(String) && secret.bytesize == 32
19
+ raise ArgumentError, unavailable_message
20
+ end
21
+
22
+ OpenSSL::HMAC.hexdigest("SHA256", secret, JSON.generate(components)).freeze
23
+ end
24
+
25
+ def component(value, invalid_message:)
26
+ unless value.is_a?(String) && value.valid_encoding? && !value.empty? &&
27
+ value.bytesize <= MAX_COMPONENT_BYTES
28
+ raise ArgumentError, invalid_message
29
+ end
30
+
31
+ value
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Hitch
6
+ module MCP
7
+ module Internal
8
+ # Exact binding of an incoming request to the canonical resource URI:
9
+ # scheme, path, query, effective port, and an allowlisted Host header
10
+ # parsed strictly (no lists, no controls, bounded length).
11
+ module HostAuthority
12
+ module_function
13
+
14
+ def allowed?(request)
15
+ resource = URI.parse(Hitch.configuration.resource_uri.to_s)
16
+ hostname, explicit_port = parse_authority(request.get_header("HTTP_HOST"))
17
+ resource_path = resource.path.empty? ? "/" : resource.path
18
+ return false unless hostname
19
+ return false unless request.get_header("rack.url_scheme") == resource.scheme
20
+ return false unless request.get_header("PATH_INFO") == resource_path
21
+ return false unless request.get_header("QUERY_STRING").to_s == resource.query.to_s
22
+
23
+ return false unless (explicit_port || resource.default_port) == resource.port
24
+
25
+ allowed_hosts = [ resource.hostname&.downcase, *Hitch.configuration.allowed_hosts ].compact.uniq
26
+ allowed_hosts.include?(hostname)
27
+ rescue URI::InvalidURIError
28
+ false
29
+ end
30
+
31
+ def parse_authority(value)
32
+ return [ nil, nil ] unless value.is_a?(String) && value.valid_encoding?
33
+ return [ nil, nil ] if value.empty? || value.bytesize > 512
34
+ return [ nil, nil ] if value.include?(",") || value.match?(/[\x00-\x20\x7F]/)
35
+
36
+ match = if value.start_with?("[")
37
+ /\A\[([0-9A-Fa-f:.]+)\](?::([0-9]{1,5}))?\z/.match(value)
38
+ else
39
+ /\A([^:\[\]]+)(?::([0-9]{1,5}))?\z/.match(value)
40
+ end
41
+ return [ nil, nil ] unless match
42
+
43
+ port = match[2] && Integer(match[2], exception: false)
44
+ return [ nil, nil ] if port && !port.between?(1, 65_535)
45
+
46
+ [ match[1].downcase, port ]
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Hitch
6
+ module MCP
7
+ module Internal
8
+ # Shared recursive helpers for the JSON-value structures that cross MCP
9
+ # boundaries. Keys are never symbolized (INV-MCP-007).
10
+ module JsonValues
11
+ # Every rejecting boundary supplies its own on_invalid; this fires only
12
+ # when a policy rejects without one.
13
+ DEFAULT_HANDLER = lambda do |reason, _detail|
14
+ raise ArgumentError, "JSON value copy rejected (#{reason})"
15
+ end
16
+
17
+ module_function
18
+
19
+ # String/Symbol-indifferent read that prefers the key exactly as given.
20
+ def read(hash, key)
21
+ return unless hash.is_a?(Hash)
22
+ return hash[key] if hash.key?(key)
23
+
24
+ hash[key.is_a?(Symbol) ? key.to_s : key.to_sym]
25
+ end
26
+
27
+ # The one deep JSON copier. Each boundary states its policy:
28
+ # keys: :as_is, :string (String only), :stringify_symbols,
29
+ # :preserve (String copied, Symbol kept), or :to_s
30
+ # symbols: Symbol values — :keep, :to_s, or :reject
31
+ # foreign: values outside the JSON universe — :keep, :to_s, or :reject
32
+ # finite: reject non-finite Floats
33
+ # duplicates: keys that collide after normalization — :replace or :reject
34
+ # freeze: deep-freeze the copy
35
+ # max_depth / max_objects: structural caps
36
+ # on_invalid receives (reason, detail) and either raises or returns the
37
+ # replacement for the entire copy. Reasons: :recursive, :depth,
38
+ # :objects, :key, :duplicate_key, :non_finite, :foreign.
39
+ def copy(value, keys: :as_is, symbols: :keep, foreign: :keep, finite: false,
40
+ duplicates: :replace, freeze: false, max_depth: nil, max_objects: nil,
41
+ on_invalid: DEFAULT_HANDLER)
42
+ Copier.new(
43
+ keys:, symbols:, foreign:, finite:, duplicates:,
44
+ freeze:, max_depth:, max_objects:, on_invalid:
45
+ ).call(value)
46
+ end
47
+
48
+ def deep_string_copy_and_freeze(value)
49
+ copy(value, keys: :to_s, freeze: true)
50
+ end
51
+
52
+ def deep_freeze(value)
53
+ case value
54
+ when Hash
55
+ value.each { |key, child| deep_freeze(key); deep_freeze(child) }
56
+ when Array
57
+ value.each { |child| deep_freeze(child) }
58
+ end
59
+ value.freeze
60
+ end
61
+
62
+ class Copier
63
+ REPLACED = Object.new
64
+ private_constant :REPLACED
65
+
66
+ def initialize(keys:, symbols:, foreign:, finite:, duplicates:,
67
+ freeze:, max_depth:, max_objects:, on_invalid:)
68
+ @keys = keys
69
+ @symbols = symbols
70
+ @foreign = foreign
71
+ @finite = finite
72
+ @duplicates = duplicates
73
+ @freeze = freeze
74
+ @max_depth = max_depth
75
+ @max_objects = max_objects
76
+ @on_invalid = on_invalid
77
+ @seen = Set.new
78
+ @objects = 0
79
+ end
80
+
81
+ def call(value)
82
+ catch(REPLACED) { walk(value, 1) }
83
+ end
84
+
85
+ private
86
+
87
+ def walk(value, depth)
88
+ invalid!(:depth, nil) if @max_depth && depth > @max_depth
89
+ return if value.nil?
90
+
91
+ copied = case value
92
+ when Hash then walk_hash(value, depth)
93
+ when Array then walk_array(value, depth)
94
+ when String then value.dup
95
+ when Symbol then symbol_value(value)
96
+ when Float
97
+ invalid!(:non_finite, nil) if @finite && !value.finite?
98
+
99
+ value
100
+ when Integer, TrueClass, FalseClass
101
+ value
102
+ else
103
+ foreign_value(value)
104
+ end
105
+ @freeze ? copied.freeze : copied
106
+ end
107
+
108
+ def walk_hash(hash, depth)
109
+ invalid!(:recursive, nil) unless @seen.add?(hash.object_id)
110
+
111
+ @objects += 1
112
+ invalid!(:objects, nil) if @max_objects && @objects > @max_objects
113
+
114
+ hash.each_with_object({}) do |(key, child), result|
115
+ copied_key = copy_key(key)
116
+ invalid!(:duplicate_key, copied_key) if @duplicates == :reject && result.key?(copied_key)
117
+
118
+ result[copied_key] = walk(child, depth + 1)
119
+ end
120
+ ensure
121
+ @seen.delete(hash.object_id)
122
+ end
123
+
124
+ def walk_array(array, depth)
125
+ invalid!(:recursive, nil) unless @seen.add?(array.object_id)
126
+
127
+ array.map { |child| walk(child, depth + 1) }
128
+ ensure
129
+ @seen.delete(array.object_id)
130
+ end
131
+
132
+ def copy_key(key)
133
+ case @keys
134
+ when :as_is then key
135
+ when :to_s then key.to_s
136
+ when :string
137
+ invalid!(:key, key) unless key.is_a?(String)
138
+
139
+ key.dup.freeze
140
+ when :stringify_symbols
141
+ case key
142
+ when String then key.dup.freeze
143
+ when Symbol then key.to_s
144
+ else invalid!(:key, key)
145
+ end
146
+ when :preserve
147
+ case key
148
+ when String then key.dup.freeze
149
+ when Symbol then key
150
+ else invalid!(:key, key)
151
+ end
152
+ else
153
+ raise ArgumentError, "unknown keys policy #{@keys.inspect}"
154
+ end
155
+ end
156
+
157
+ def symbol_value(value)
158
+ case @symbols
159
+ when :keep then value
160
+ when :to_s then value.to_s
161
+ when :reject then invalid!(:foreign, nil)
162
+ else raise ArgumentError, "unknown symbols policy #{@symbols.inspect}"
163
+ end
164
+ end
165
+
166
+ def foreign_value(value)
167
+ case @foreign
168
+ when :keep then value
169
+ when :to_s then value.to_s
170
+ when :reject then invalid!(:foreign, nil)
171
+ else raise ArgumentError, "unknown foreign policy #{@foreign.inspect}"
172
+ end
173
+ end
174
+
175
+ def invalid!(reason, detail)
176
+ throw REPLACED, @on_invalid.call(reason, detail)
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ module MCP
5
+ module Internal
6
+ # A client must learn nothing from a host failure; the developer who
7
+ # wrote it must learn everything. Those are two audiences and only the
8
+ # first is a threat, so in development and test the real exception goes
9
+ # to the local log. Callers' wire responses are unchanged, and
10
+ # production stays silent.
11
+ module LocalDiagnosis
12
+ module_function
13
+
14
+ def report(subject, error = nil)
15
+ return unless Rails.env.local?
16
+
17
+ lines = [ "[hitch] #{subject}" ]
18
+ if error
19
+ lines << " #{error.class}: #{error.message}"
20
+ lines.concat(Array(error.backtrace).first(5).map { |line| " #{line}" })
21
+ end
22
+ Rails.logger&.error(lines.join("\n"))
23
+ nil
24
+ # Diagnosing a failure must not become one.
25
+ rescue StandardError, SystemStackError
26
+ nil
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ module MCP
5
+ module Internal
6
+ # Strict media negotiation for the endpoint (RFC 7231 §5.3): the request
7
+ # body must be exactly application/json, and Accept must admit both
8
+ # response framings the protocol may use.
9
+ module MediaType
10
+ ACCEPT_TYPES = %w[application/json text/event-stream].freeze
11
+ MEDIA_TYPE_PATTERN = %r{\A[A-Za-z0-9!#$%&'*+.^_`|~-]+/[A-Za-z0-9!#$%&'*+.^_`|~-]+\z}
12
+ PARAMETER_PATTERN = /\A[A-Za-z0-9!#$%&'*+.^_`|~-]+=[^;\s]+\z/
13
+ QUALITY_PATTERN = /\A(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)\z/
14
+
15
+ module_function
16
+
17
+ def json_content_type?(value)
18
+ return false unless value.is_a?(String) && value.valid_encoding?
19
+ return false if value.empty? || value.include?(",") || HeaderField::CONTROLS.match?(value)
20
+
21
+ media_type, *parameters = value.split(";", -1).map { |part| HeaderField.trim_ows(part) }
22
+ return false unless media_type&.downcase == "application/json"
23
+
24
+ parameters.all? { |parameter| parameter&.match?(PARAMETER_PATTERN) }
25
+ end
26
+
27
+ def accepts_required_types?(value)
28
+ return false unless value.is_a?(String) && value.valid_encoding?
29
+ return false if value.empty? || HeaderField::CONTROLS.match?(value)
30
+
31
+ accepted = {}
32
+ value.split(",", -1).each do |entry|
33
+ media_type, quality = accept_entry(entry)
34
+ return false unless media_type
35
+
36
+ accepted[media_type] = true if ACCEPT_TYPES.include?(media_type) && quality.positive?
37
+ end
38
+ ACCEPT_TYPES.all? { |media_type| accepted[media_type] }
39
+ end
40
+
41
+ def accept_entry(entry)
42
+ media_type, *parameters = entry.split(";", -1).map { |part| HeaderField.trim_ows(part) }
43
+ return [ nil, nil ] unless media_type&.match?(MEDIA_TYPE_PATTERN)
44
+
45
+ quality = 1.0
46
+ quality_seen = false
47
+ parameters.each do |parameter|
48
+ name, raw_value = parameter.to_s.split("=", 2)
49
+ return [ nil, nil ] unless name && raw_value
50
+ next unless name.casecmp?("q")
51
+ return [ nil, nil ] if quality_seen || !raw_value.match?(QUALITY_PATTERN)
52
+
53
+ quality_seen = true
54
+ quality = raw_value.to_f
55
+ end
56
+ [ media_type.downcase, quality ]
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Hitch
6
+ module MCP
7
+ module Internal
8
+ # Request-local structural telemetry. The state never crosses the public
9
+ # Context or SDK server_context boundary, and publication temporarily
10
+ # detaches it so hostile subscribers cannot recursively observe their own
11
+ # work as part of the request.
12
+ class Observation
13
+ REQUEST_EVENT = "request.hitch_mcp"
14
+ INVOCATION_EVENT = "invocation.hitch_mcp"
15
+ IDENTITY_SALT = "hitch/mcp/observation/v1"
16
+ CURRENT_REQUEST_KEY = :hitch_mcp_observation_request
17
+ REQUEST_ID_PATTERN = /\A[0-9a-f]{32}\z/
18
+ PRINCIPAL_TYPE_PATTERN = /\A[A-Za-z_][A-Za-z0-9_:]{0,254}\z/
19
+ PROTOCOL_OUTCOMES = {
20
+ -32_700 => "parse_error",
21
+ -32_600 => "invalid_request",
22
+ -32_601 => "method_not_found",
23
+ -32_602 => "invalid_params",
24
+ -32_603 => "internal_error",
25
+ -32_020 => "header_mismatch",
26
+ -32_022 => "unsupported_protocol"
27
+ }.freeze
28
+ HTTP_OUTCOMES = {
29
+ 200 => "complete",
30
+ 400 => "bad_request",
31
+ 401 => "unauthorized",
32
+ 403 => "forbidden",
33
+ 404 => "not_found",
34
+ 405 => "method_not_allowed",
35
+ 406 => "not_acceptable",
36
+ 413 => "request_too_large",
37
+ 415 => "unsupported_media_type",
38
+ 429 => "rate_limited",
39
+ 503 => "service_unavailable"
40
+ }.freeze
41
+ HTTP_TERMINAL_OUTCOMES = [ 406, 413, 415 ].freeze
42
+
43
+ class RequestState
44
+ attr_reader :request_id
45
+
46
+ def initialize
47
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
48
+ @request_id = SecureRandom.hex(16).freeze
49
+ @method = nil
50
+ @tool_name = nil
51
+ @principal_type = nil
52
+ @principal_key = nil
53
+ @client_key = nil
54
+ @protocol_code = nil
55
+ @request_bytes = 0
56
+ @finished = false
57
+ end
58
+
59
+ def authenticated!(principal:, client_id:)
60
+ principal_type, principal_key, client_key = Observation.identity_keys(
61
+ principal:,
62
+ client_id:
63
+ )
64
+ @principal_type = principal_type
65
+ @principal_key = principal_key
66
+ @client_key = client_key
67
+ nil
68
+ rescue StandardError, SystemStackError
69
+ Observation.report_failure(REQUEST_EVENT, "identity")
70
+ nil
71
+ end
72
+
73
+ def request_bytes!(value)
74
+ @request_bytes = value if value.is_a?(Integer) && value >= 0
75
+ nil
76
+ end
77
+
78
+ def verified!(request)
79
+ method = JsonValues.read(request, "method")
80
+ @method = method.dup.freeze if Protocol::METHODS.include?(method)
81
+ nil
82
+ rescue StandardError, SystemStackError
83
+ Observation.report_failure(REQUEST_EVENT, "verified_request")
84
+ nil
85
+ end
86
+
87
+ def tool_resolved!(name)
88
+ @tool_name = name.dup.freeze if Protocol.tool_name?(name)
89
+ nil
90
+ end
91
+
92
+ def protocol_response!(response)
93
+ error = JsonValues.read(response, "error")
94
+ code = JsonValues.read(error, "code")
95
+ @protocol_code = code if code.is_a?(Integer)
96
+ nil
97
+ rescue StandardError, SystemStackError
98
+ Observation.report_failure(REQUEST_EVENT, "protocol_response")
99
+ nil
100
+ end
101
+
102
+ def start_invocation(tool_name:)
103
+ return if @finished
104
+ return unless Protocol.tool_name?(tool_name)
105
+
106
+ InvocationState.new(request_id:, tool_name:)
107
+ end
108
+
109
+ def finish!(response:)
110
+ return if @finished
111
+
112
+ @finished = true
113
+ http_status = response.status.to_i
114
+ payload = {
115
+ schema_version: 1,
116
+ request_id:,
117
+ method: @method,
118
+ tool_name: @tool_name,
119
+ principal_type: @principal_type,
120
+ principal_key: @principal_key,
121
+ client_key: @client_key,
122
+ http_status:,
123
+ protocol_code: @protocol_code,
124
+ outcome: outcome(http_status),
125
+ request_bytes: @request_bytes,
126
+ response_bytes: Observation.response_bytes(response),
127
+ duration_ms: duration_ms
128
+ }.freeze
129
+ Observation.publish(REQUEST_EVENT, payload)
130
+ nil
131
+ rescue StandardError, SystemStackError
132
+ Observation.report_failure(REQUEST_EVENT, "request_finish")
133
+ nil
134
+ end
135
+
136
+ private
137
+
138
+ def outcome(http_status)
139
+ return HTTP_OUTCOMES.fetch(http_status) if HTTP_TERMINAL_OUTCOMES.include?(http_status)
140
+
141
+ PROTOCOL_OUTCOMES.fetch(@protocol_code) do
142
+ HTTP_OUTCOMES.fetch(http_status, "http_error")
143
+ end
144
+ end
145
+
146
+ def duration_ms
147
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at
148
+ [ (elapsed * 1_000).round(3), 0.0 ].max
149
+ end
150
+ end
151
+
152
+ class InvocationState
153
+ def initialize(request_id:, tool_name:)
154
+ @request_id = request_id
155
+ @tool_name = tool_name.dup.freeze
156
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
157
+ @argument_policy = "not_reached"
158
+ @executed = false
159
+ @result_category = "generic_error"
160
+ @finished = false
161
+ end
162
+
163
+ def argument_policy_allowed!
164
+ @argument_policy = "allowed"
165
+ nil
166
+ end
167
+
168
+ def execution_started!
169
+ @executed = true
170
+ nil
171
+ end
172
+
173
+ def result_normalized!(kind:)
174
+ @result_category = kind == :error ? "explicit_error" : "success"
175
+ nil
176
+ end
177
+
178
+ def failed!(phase:, expected_denial:)
179
+ if phase == :authorization
180
+ @argument_policy = expected_denial ? "denied" : "failed"
181
+ end
182
+ @result_category = "generic_error"
183
+ nil
184
+ end
185
+
186
+ def finish!
187
+ return if @finished
188
+
189
+ @finished = true
190
+ payload = {
191
+ schema_version: 1,
192
+ request_id: @request_id,
193
+ tool_name: @tool_name,
194
+ availability: "available",
195
+ argument_policy: @argument_policy,
196
+ executed: @executed,
197
+ result_category: @result_category,
198
+ duration_ms: duration_ms
199
+ }.freeze
200
+ Observation.publish(INVOCATION_EVENT, payload)
201
+ nil
202
+ rescue StandardError, SystemStackError
203
+ Observation.report_failure(INVOCATION_EVENT, "invocation_finish")
204
+ nil
205
+ end
206
+
207
+ private
208
+
209
+ def duration_ms
210
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at
211
+ [ (elapsed * 1_000).round(3), 0.0 ].max
212
+ end
213
+ end
214
+
215
+ class << self
216
+ def with_request_state
217
+ previous = ActiveSupport::IsolatedExecutionState[CURRENT_REQUEST_KEY]
218
+ state = begin
219
+ created = RequestState.new
220
+ ActiveSupport::IsolatedExecutionState[CURRENT_REQUEST_KEY] = created
221
+ created
222
+ rescue StandardError, SystemStackError
223
+ report_failure(REQUEST_EVENT, "request_start")
224
+ nil
225
+ end
226
+ yield state
227
+ ensure
228
+ begin
229
+ if previous
230
+ ActiveSupport::IsolatedExecutionState[CURRENT_REQUEST_KEY] = previous
231
+ else
232
+ ActiveSupport::IsolatedExecutionState.delete(CURRENT_REQUEST_KEY)
233
+ end
234
+ rescue StandardError, SystemStackError
235
+ report_failure(REQUEST_EVENT, "request_cleanup")
236
+ end
237
+ end
238
+
239
+ def start_invocation(tool_name:)
240
+ current_request&.start_invocation(tool_name:)
241
+ rescue StandardError, SystemStackError
242
+ report_failure(INVOCATION_EVENT, "invocation_start")
243
+ nil
244
+ end
245
+
246
+ def current_request_id
247
+ correlation_id(current_request&.request_id)
248
+ rescue StandardError, SystemStackError
249
+ nil
250
+ end
251
+
252
+ def publish(event_name, payload)
253
+ previous = ActiveSupport::IsolatedExecutionState[CURRENT_REQUEST_KEY]
254
+ ActiveSupport::IsolatedExecutionState.delete(CURRENT_REQUEST_KEY)
255
+ ActiveSupport::Notifications.instrument(event_name, payload)
256
+ rescue StandardError, SystemStackError
257
+ report_failure(event_name, "subscriber")
258
+ nil
259
+ ensure
260
+ if previous
261
+ ActiveSupport::IsolatedExecutionState[CURRENT_REQUEST_KEY] = previous
262
+ else
263
+ ActiveSupport::IsolatedExecutionState.delete(CURRENT_REQUEST_KEY)
264
+ end
265
+ end
266
+
267
+ def identity_keys(principal:, client_id:, key_generator: Rails.application.key_generator)
268
+ record_class = principal.class.respond_to?(:base_class) ? principal.class.base_class : principal.class
269
+ principal_type = record_class.name
270
+ unless principal_type.is_a?(String) && PRINCIPAL_TYPE_PATTERN.match?(principal_type)
271
+ raise ArgumentError, "MCP observation principal type is invalid"
272
+ end
273
+
274
+ invalid_message = "MCP observation identity is invalid"
275
+ principal_id = HmacIdentity.component(principal.id.to_s, invalid_message:)
276
+ client = HmacIdentity.component(client_id, invalid_message:)
277
+ principal_key = identity_digest([ "principal", principal_type, principal_id ], key_generator)
278
+ client_key = identity_digest([ "client", client ], key_generator)
279
+ [ principal_type.dup.freeze, principal_key, client_key ].freeze
280
+ end
281
+
282
+ def response_bytes(response)
283
+ body = response.body
284
+ return body.bytesize if body.is_a?(String)
285
+ return 0 if body.nil?
286
+
287
+ Array(body).sum { |part| part.to_s.bytesize }
288
+ end
289
+
290
+ def report_failure(event_name, category)
291
+ SanitizedReport.emit(
292
+ source: "hitch.mcp.observation",
293
+ message: "Hitch MCP observation delivery failed",
294
+ context: {
295
+ hitch_mcp_category: "observation_#{category}".freeze,
296
+ hitch_mcp_event: event_name.dup.freeze
297
+ }.freeze
298
+ )
299
+ rescue StandardError, SystemStackError
300
+ nil
301
+ end
302
+
303
+ private
304
+
305
+ def correlation_id(request_id)
306
+ return unless request_id.instance_of?(String) && REQUEST_ID_PATTERN.match?(request_id)
307
+
308
+ request_id.dup.freeze
309
+ end
310
+
311
+ def current_request
312
+ ActiveSupport::IsolatedExecutionState[CURRENT_REQUEST_KEY]
313
+ end
314
+
315
+ def identity_digest(components, key_generator)
316
+ HmacIdentity.digest(
317
+ salt: IDENTITY_SALT,
318
+ components: components,
319
+ key_generator: key_generator,
320
+ unavailable_message: "MCP observation key is unavailable"
321
+ )
322
+ end
323
+ end
324
+
325
+ private_constant :RequestState, :InvocationState,
326
+ :REQUEST_EVENT, :INVOCATION_EVENT, :IDENTITY_SALT,
327
+ :CURRENT_REQUEST_KEY, :PRINCIPAL_TYPE_PATTERN,
328
+ :REQUEST_ID_PATTERN, :PROTOCOL_OUTCOMES, :HTTP_OUTCOMES,
329
+ :HTTP_TERMINAL_OUTCOMES
330
+ end
331
+ end
332
+ end
333
+ end