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,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
|