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,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubygems/requirement"
4
+ require "rubygems/version"
5
+
6
+ module Hitch
7
+ module MCP
8
+ module Internal
9
+ class SDKAdapter
10
+ class ResponseNormalizer
11
+ SERVER_INFO_META_KEY = "io.modelcontextprotocol/serverInfo"
12
+
13
+ class << self
14
+ def call(response:, method:, server_info:, request_id:, tool_response:)
15
+ new(response, method, server_info, request_id, tool_response).call
16
+ end
17
+ end
18
+
19
+ def initialize(response, method, server_info, request_id, tool_response)
20
+ @response = response
21
+ @method = method
22
+ @server_info = server_info
23
+ @request_id = request_id
24
+ @tool_response = tool_response
25
+ end
26
+
27
+ def call
28
+ return internal_error unless response.is_a?(Hash)
29
+
30
+ result_present = member?(response, :result)
31
+ error_present = member?(response, :error)
32
+ return internal_error if result_present == error_present
33
+ return internal_error if duplicate_member?(response, :result) || duplicate_member?(response, :error)
34
+
35
+ error = read(response, :error)
36
+ if error_present
37
+ return internal_error unless error.is_a?(Hash) && read(error, :code).is_a?(Integer)
38
+
39
+ return normalize_error
40
+ end
41
+
42
+ result = read(response, :result)
43
+ return internal_error unless result.is_a?(Hash)
44
+
45
+ normalized = {
46
+ jsonrpc: "2.0",
47
+ id: copy(request_id),
48
+ result: copy(result)
49
+ }
50
+ result = normalized.fetch(:result)
51
+
52
+ normalize_tool_error(result) if read(result, :isError) == true
53
+ normalize_final_result(result)
54
+ deep_freeze(normalized)
55
+ end
56
+
57
+ private
58
+
59
+ attr_reader :response, :method, :server_info, :request_id, :tool_response
60
+
61
+ def internal_error
62
+ deep_freeze(
63
+ jsonrpc: "2.0",
64
+ id: copy(request_id),
65
+ error: { code: -32603, message: "Internal error" }
66
+ )
67
+ end
68
+
69
+ def normalize_error
70
+ error = read(response, :error)
71
+ code = read(error, :code)
72
+ deep_freeze(
73
+ jsonrpc: "2.0",
74
+ id: copy(request_id),
75
+ error: { code: code, message: public_error_message(code) }
76
+ )
77
+ end
78
+
79
+ # Hitch owns the final wire shape on every supported SDK line rather
80
+ # than trusting what crossed the boundary. On mcp 1.1 this also
81
+ # papered over gaps since fixed upstream (ruby-sdk#389,
82
+ # modelcontextprotocol#3040); on mcp >= 1.2 the SDK's own modern
83
+ # shaping is envelope-gated and Hitch strips _meta before the SDK
84
+ # sees it, so the shape is Hitch's job either way. resultType is
85
+ # always "complete": multi-round-trip requests are unsupported.
86
+ def normalize_final_result(result)
87
+ result[:resultType] = "complete"
88
+ normalize_discovery(result) if method == "server/discover"
89
+ normalize_private_listing(result) if %w[server/discover tools/list].include?(method)
90
+ normalize_server_identity(result)
91
+ end
92
+
93
+ def normalize_tool_error(result)
94
+ explicit_text = ResultNormalizer.explicit_error_text(tool_response)
95
+ result.delete(:structuredContent)
96
+ result.delete("structuredContent")
97
+ result.delete(:_meta)
98
+ result.delete("_meta")
99
+ result[:content] = [
100
+ { type: "text", text: explicit_text || Protocol::GENERIC_TOOL_ERROR }
101
+ ]
102
+ result[:isError] = true
103
+ end
104
+
105
+ def normalize_discovery(result)
106
+ result.delete(:serverInfo)
107
+ result.delete("serverInfo")
108
+ result[:supportedVersions] = [ Protocol::VERSION ]
109
+ result[:capabilities] = { tools: {} }
110
+ end
111
+
112
+ def server_identity
113
+ server_info.each_with_object({}) do |(key, value), identity|
114
+ identity[key.to_s] = stringify(value) unless key.to_s == "instructions"
115
+ end
116
+ end
117
+
118
+ def normalize_server_identity(result)
119
+ meta = read(result, :_meta)
120
+ meta = meta.is_a?(Hash) ? stringify(meta) : {}
121
+ meta[SERVER_INFO_META_KEY] = server_identity
122
+ result[:_meta] = meta
123
+ end
124
+
125
+ def normalize_private_listing(result)
126
+ result[:ttlMs] = 0
127
+ result[:cacheScope] = "private"
128
+ end
129
+
130
+ def public_error_message(code)
131
+ case code
132
+ when -32601 then "Method not found"
133
+ when -32602 then "Invalid params"
134
+ when -32603 then "Internal error"
135
+ else "Request failed"
136
+ end
137
+ end
138
+
139
+ def read(hash, key)
140
+ JsonValues.read(hash, key)
141
+ end
142
+
143
+ def member?(hash, key)
144
+ hash.key?(key) || hash.key?(key.to_s)
145
+ end
146
+
147
+ def duplicate_member?(hash, key)
148
+ hash.key?(key) && hash.key?(key.to_s)
149
+ end
150
+
151
+ def copy(value)
152
+ JsonValues.copy(value)
153
+ end
154
+
155
+ def stringify(value)
156
+ case value
157
+ when Hash then value.to_h { |key, child| [ key.to_s, stringify(child) ] }
158
+ when Array then value.map { |child| stringify(child) }
159
+ when String then value.dup
160
+ else value
161
+ end
162
+ end
163
+
164
+ def deep_freeze(value)
165
+ JsonValues.deep_freeze(value)
166
+ end
167
+ end
168
+
169
+ private_constant :ResponseNormalizer
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+
5
+ module Hitch
6
+ module MCP
7
+ module Internal
8
+ class SDKAdapter
9
+ STRUCTURAL_PARAMS = {
10
+ "server/discover" => [],
11
+ "tools/list" => %w[cursor],
12
+ "tools/call" => %w[name arguments]
13
+ }.freeze
14
+ SDK_REQUEST_ID = "hitch_request"
15
+
16
+ class << self
17
+ def call(**arguments)
18
+ new(**arguments).call
19
+ end
20
+
21
+ # Builds the SDK-facing tool wrapper once per registry snapshot —
22
+ # ::MCP::Tool.define compiles the JSON schemas eagerly, which is the
23
+ # cost this memoization exists to stop paying per tool per request.
24
+ # The block closes over nothing request-scoped: each request routes
25
+ # through the dispatcher its own ::MCP::Server carries in
26
+ # server_context, so a shared wrapper can never see another
27
+ # request's principal.
28
+ def build_sdk_tool(name:, description:, input_schema:, output_schema: nil, annotations: nil)
29
+ name = validate_tool_name!(name)
30
+ ::MCP::Tool.define(
31
+ name: name,
32
+ description: description,
33
+ input_schema: input_schema,
34
+ output_schema: output_schema,
35
+ annotations: annotations
36
+ ) do |server_context:, **arguments|
37
+ server_context.fetch(:hitch_dispatch).call(name, server_context, arguments)
38
+ end
39
+ end
40
+
41
+ def validate_tool_name!(name)
42
+ return name if Protocol.tool_name?(name)
43
+
44
+ raise ArgumentError,
45
+ "tool name must be 1-#{Protocol::MAX_TOOL_NAME_LENGTH} ASCII letters, digits, " \
46
+ "underscore, dot, or dash"
47
+ end
48
+ end
49
+
50
+ def initialize(verified_request:, tools:, context:, server_info:)
51
+ @request = frozen_hash!(verified_request)
52
+ @tools = tools.dup.freeze
53
+ @context = context
54
+ @server_info = frozen_hash!(server_info)
55
+ @tool_responses = []
56
+ end
57
+
58
+ def call
59
+ return method_not_found unless Protocol::METHODS.include?(request_method)
60
+ return invalid_params if reserved_server_context?
61
+
62
+ response = build_server.handle(structural_request)
63
+ ResponseNormalizer.call(
64
+ response: response,
65
+ method: request_method,
66
+ server_info: server_info,
67
+ request_id: read(request, "id"),
68
+ tool_response: @tool_responses.last
69
+ )
70
+ end
71
+
72
+ private
73
+
74
+ attr_reader :request, :tools, :context, :server_info
75
+
76
+ def request_method
77
+ read(request, "method")
78
+ end
79
+
80
+ # The server itself stays per-request on purpose: the SDK binds
81
+ # server_context at construction (1.2.0 server.rb:222) with no
82
+ # per-handle alternative, so a fresh thin server is what keeps one
83
+ # request's principal out of another's. The
84
+ # expensive part — schema compilation inside ::MCP::Tool.define — is
85
+ # memoized per snapshot entry and only assembled here.
86
+ def build_server
87
+ # The endpoint boundary has already validated server_info against the
88
+ # supported key set; the SDK constructor only needs symbol keywords.
89
+ ::MCP::Server.new(
90
+ **server_info.transform_keys(&:to_sym),
91
+ tools: sdk_tools,
92
+ capabilities: { tools: {} },
93
+ server_context: { hitch_context: context, hitch_dispatch: dispatch }.freeze,
94
+ configuration: sdk_configuration,
95
+ ttl_ms: 0,
96
+ cache_scope: "private"
97
+ )
98
+ end
99
+
100
+ def sdk_tools
101
+ tools.map do |definition|
102
+ next definition.sdk_tool if definition.respond_to?(:sdk_tool)
103
+
104
+ annotations = definition.annotations if definition.respond_to?(:annotations)
105
+ self.class.build_sdk_tool(
106
+ name: definition.name,
107
+ description: definition.description,
108
+ input_schema: definition.input_schema,
109
+ output_schema: definition.output_schema,
110
+ annotations: annotations
111
+ )
112
+ end
113
+ end
114
+
115
+ # Request-scoped state rides this lambda instead of the shared tool
116
+ # wrappers: the response capture feeds ResponseNormalizer's explicit
117
+ # tool-error text, and the name lookup resolves the wrapper's call to
118
+ # this request's admitted definitions.
119
+ def dispatch
120
+ tools_by_name = tools.to_h { |definition| [ definition.name, definition ] }
121
+ captured = @tool_responses
122
+ lambda do |name, server_context, arguments|
123
+ tools_by_name.fetch(name).call(server_context:, **arguments)
124
+ .tap { |response| captured << response }
125
+ end.freeze
126
+ end
127
+
128
+ # No protocol_version pin: on the modern wire every request carries its
129
+ # version, and both supported SDK lines default their stable version to
130
+ # 2026-07-28 — the value we used to pin. mcp 1.2.0 rejects pinning a
131
+ # modern version outright.
132
+ # validate_tool_call_results stays explicitly false (also the SDK
133
+ # default on both supported lines): Hitch's ResultNormalizer already
134
+ # schema-validates every structured result inside framework-owned
135
+ # Tool.call, before the SDK sees it, so the SDK pass was a second
136
+ # validation of the same bytes. Hitch's is the one that stays — the
137
+ # boundary posture is to not trust the SDK's.
138
+ def sdk_configuration
139
+ ::MCP::Configuration.new(
140
+ validate_tool_call_arguments: true,
141
+ validate_tool_call_results: false,
142
+ exception_reporter: ->(_exception, _data) { },
143
+ around_request: ->(_data, &handler) { handler.call },
144
+ instrumentation_callback: ->(_data) { }
145
+ )
146
+ end
147
+
148
+ def structural_request
149
+ params = read(request, "params")
150
+ structural_params = STRUCTURAL_PARAMS.fetch(request_method).each_with_object({}) do |key, result|
151
+ copy_fixed_key(result, params, key)
152
+ end
153
+
154
+ # SDK 1.1 merges request _meta into a Hash server_context. Hitch has
155
+ # already verified and copied that metadata into its own Context, so it
156
+ # must not cross this boundary and dilute the one authority wrapper.
157
+
158
+ deep_freeze(
159
+ jsonrpc: read(request, "jsonrpc"),
160
+ id: SDK_REQUEST_ID,
161
+ method: request_method,
162
+ params: structural_params
163
+ )
164
+ end
165
+
166
+ def reserved_server_context?
167
+ return false unless request_method == "tools/call"
168
+
169
+ arguments = read(read(request, "params"), "arguments")
170
+ arguments.instance_of?(Hash) && arguments.key?("server_context")
171
+ end
172
+
173
+ def method_not_found
174
+ protocol_error(-32601, "Method not found")
175
+ end
176
+
177
+ def invalid_params
178
+ protocol_error(-32602, "Invalid params")
179
+ end
180
+
181
+ def protocol_error(code, message)
182
+ {
183
+ jsonrpc: "2.0",
184
+ id: read(request, "id"),
185
+ error: { code: code, message: message }
186
+ }.then { |response| deep_freeze(response) }
187
+ end
188
+
189
+ def copy_fixed_key(target, source, key)
190
+ return unless source.is_a?(Hash)
191
+
192
+ if source.key?(key)
193
+ target[key.to_sym] = JsonValues.deep_string_copy_and_freeze(source[key])
194
+ elsif source.key?(key.to_sym)
195
+ target[key.to_sym] = JsonValues.deep_string_copy_and_freeze(source[key.to_sym])
196
+ end
197
+ end
198
+
199
+ def read(hash, key)
200
+ JsonValues.read(hash, key)
201
+ end
202
+
203
+ # Both inputs arrive already normalized — VerifiedRequest's product and
204
+ # the memoized ServerInfo are deep-frozen, string-keyed copies, and
205
+ # those two producers own that guarantee. The adapter spot-checks the
206
+ # cheap top level and holds references instead of re-walking
207
+ # up-to-1MiB request trees on every call.
208
+ def frozen_hash!(value)
209
+ unless value.is_a?(Hash) && value.frozen?
210
+ raise ArgumentError, "verified request and server info must be frozen Hash values"
211
+ end
212
+
213
+ value
214
+ end
215
+
216
+ def deep_freeze(value)
217
+ JsonValues.deep_freeze(value)
218
+ end
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ module MCP
5
+ module Internal
6
+ # Validates the host's mcp.server_info result into the frozen,
7
+ # string-keyed identity hash the SDK boundary requires.
8
+ module ServerInfo
9
+ KEY_MAP = {
10
+ "name" => "name",
11
+ "version" => "version",
12
+ "title" => "title",
13
+ "instructions" => "instructions",
14
+ name: "name",
15
+ version: "version",
16
+ title: "title",
17
+ instructions: "instructions"
18
+ }.freeze
19
+
20
+ module_function
21
+
22
+ def normalize(value)
23
+ raise ArgumentError, "mcp.server_info must return a Hash" unless value.is_a?(Hash)
24
+
25
+ unknown = value.keys - KEY_MAP.keys
26
+ raise ArgumentError, "mcp.server_info contains unsupported keys" unless unknown.empty?
27
+
28
+ canonical_keys = value.keys.map { |key| KEY_MAP.fetch(key) }
29
+ raise ArgumentError, "mcp.server_info contains duplicate keys" unless canonical_keys.uniq == canonical_keys
30
+
31
+ normalized = value.to_h do |key, field|
32
+ [ KEY_MAP.fetch(key).dup.freeze, string_field(field) ]
33
+ end
34
+ %w[name version].each do |required|
35
+ field = normalized[required]
36
+ raise ArgumentError, "mcp.server_info requires name and version" unless field.is_a?(String) && !field.empty?
37
+ end
38
+ normalized.freeze
39
+ end
40
+
41
+ def string_field(value)
42
+ raise ArgumentError, "mcp.server_info values must be strings" unless value.is_a?(String)
43
+
44
+ value.dup.freeze
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Hitch
6
+ module MCP
7
+ module Internal
8
+ # Builds the one immutable request value that may cross into the SDK.
9
+ # HTTP media and body-size admission happen before this object sees bytes.
10
+ class VerifiedRequest
11
+ CLIENT_INFO_KEY = "io.modelcontextprotocol/clientInfo"
12
+ CLIENT_INFO_STRING_KEYS = %w[name version title description websiteUrl].freeze
13
+
14
+ class Failure < StandardError
15
+ attr_reader :http_status, :code, :request_id, :data
16
+
17
+ def initialize(http_status:, code:, message:, request_id: nil, data: nil)
18
+ @http_status = http_status
19
+ @code = code
20
+ @request_id = request_id
21
+ @data = data
22
+ super(message)
23
+ end
24
+ end
25
+
26
+ class << self
27
+ def call(raw_body:, headers:)
28
+ new(raw_body, headers).call
29
+ end
30
+ end
31
+
32
+ def initialize(raw_body, headers)
33
+ @raw_body = raw_body
34
+ @headers = headers
35
+ end
36
+
37
+ def call
38
+ parsed = parse
39
+ validate_json_values!(parsed)
40
+ request_id = validate_envelope!(parsed)
41
+ validate_metadata!(parsed, request_id)
42
+ validate_protocol_and_method_headers!(parsed, request_id)
43
+ validate_method!(parsed, request_id)
44
+ validate_call_shape!(parsed, request_id)
45
+ validate_name_header!(parsed, request_id)
46
+ validate_reserved_arguments!(parsed, request_id)
47
+ # The parse output is freshly built and aliased by nothing, so the
48
+ # product needs freezing, not another copy of the whole tree.
49
+ JsonValues.deep_freeze(parsed)
50
+ end
51
+
52
+ private
53
+
54
+ attr_reader :raw_body, :headers
55
+
56
+ def parse
57
+ source = raw_body.dup.force_encoding(Encoding::UTF_8)
58
+ parse_error! unless source.valid_encoding?
59
+
60
+ JSON.parse(source, allow_duplicate_key: false)
61
+ rescue JSON::ParserError => error
62
+ if error.message.include?("duplicate key")
63
+ failure!(400, -32600, "Invalid Request")
64
+ else
65
+ parse_error!
66
+ end
67
+ rescue JSON::GeneratorError, JSON::NestingError
68
+ parse_error!
69
+ end
70
+
71
+ def validate_json_values!(value)
72
+ case value
73
+ when Hash
74
+ value.each do |key, child|
75
+ parse_error! unless key.is_a?(String) && key.valid_encoding?
76
+
77
+ validate_json_values!(child)
78
+ end
79
+ when Array
80
+ value.each { |child| validate_json_values!(child) }
81
+ when String
82
+ parse_error! unless value.valid_encoding?
83
+ when Float
84
+ invalid_request! unless value.finite?
85
+ end
86
+ end
87
+
88
+ def validate_envelope!(request)
89
+ invalid_request! unless request.is_a?(Hash)
90
+ invalid_request! unless request["jsonrpc"] == "2.0"
91
+ invalid_request! unless request.key?("id")
92
+ invalid_request! unless request["id"].is_a?(String) || request["id"].is_a?(Numeric)
93
+ invalid_request! unless request["method"].is_a?(String)
94
+ invalid_request! unless request["params"].is_a?(Hash)
95
+
96
+ request.fetch("id")
97
+ end
98
+
99
+ def validate_metadata!(request, request_id)
100
+ metadata = request.fetch("params")["_meta"]
101
+ invalid_params!(request_id, 400) unless metadata.is_a?(Hash)
102
+
103
+ version = metadata["io.modelcontextprotocol/protocolVersion"]
104
+ capabilities = metadata["io.modelcontextprotocol/clientCapabilities"]
105
+ invalid_params!(request_id, 400) unless version.is_a?(String)
106
+ invalid_params!(request_id, 400) unless capabilities.is_a?(Hash)
107
+
108
+ return unless metadata.key?(CLIENT_INFO_KEY)
109
+
110
+ client_info = metadata[CLIENT_INFO_KEY]
111
+
112
+ valid = client_info.is_a?(Hash) &&
113
+ client_info["name"].is_a?(String) && !client_info["name"].empty? &&
114
+ client_info["version"].is_a?(String) && !client_info["version"].empty? &&
115
+ valid_client_info_strings?(client_info) &&
116
+ valid_client_info_icons?(client_info)
117
+ invalid_params!(request_id, 400) unless valid
118
+ end
119
+
120
+ def valid_client_info_strings?(client_info)
121
+ CLIENT_INFO_STRING_KEYS.all? do |key|
122
+ !client_info.key?(key) || client_info[key].is_a?(String)
123
+ end
124
+ end
125
+
126
+ def valid_client_info_icons?(client_info)
127
+ return true unless client_info.key?("icons")
128
+
129
+ icons = client_info["icons"]
130
+ icons.is_a?(Array) && icons.all? do |icon|
131
+ icon.is_a?(Hash) &&
132
+ icon["src"].is_a?(String) &&
133
+ (!icon.key?("mimeType") || icon["mimeType"].is_a?(String)) &&
134
+ (!icon.key?("sizes") || icon["sizes"].is_a?(Array) && icon["sizes"].all?(String))
135
+ end
136
+ end
137
+
138
+ def validate_protocol_and_method_headers!(request, request_id)
139
+ metadata = request.fetch("params").fetch("_meta")
140
+ protocol_version = HeaderField.single(headers.fetch(:protocol_version, nil))
141
+ method = HeaderField.single(headers.fetch(:method, nil))
142
+
143
+ header_mismatch!(request_id) unless protocol_version == metadata.fetch("io.modelcontextprotocol/protocolVersion")
144
+ header_mismatch!(request_id) unless method == request.fetch("method")
145
+
146
+ return if protocol_version == Protocol::VERSION
147
+
148
+ failure!(
149
+ 400,
150
+ -32022,
151
+ "Unsupported protocol version",
152
+ request_id: request_id,
153
+ data: {
154
+ "supportedVersions" => [ Protocol::VERSION ],
155
+ "supported" => [ Protocol::VERSION ],
156
+ "requested" => protocol_version
157
+ }
158
+ )
159
+ end
160
+
161
+ def validate_method!(request, request_id)
162
+ return if Protocol::METHODS.include?(request.fetch("method"))
163
+
164
+ failure!(404, -32601, "Method not found", request_id: request_id)
165
+ end
166
+
167
+ def validate_call_shape!(request, request_id)
168
+ return unless request.fetch("method") == "tools/call"
169
+
170
+ params = request.fetch("params")
171
+ name = params["name"]
172
+ arguments = params["arguments"]
173
+ invalid_params!(request_id, 200) unless name.is_a?(String) && !name.empty?
174
+ invalid_params!(request_id, 200) unless !params.key?("arguments") || arguments.is_a?(Hash)
175
+ end
176
+
177
+ def validate_name_header!(request, request_id)
178
+ supplied_name = headers.fetch(:name, nil)
179
+
180
+ if request.fetch("method") == "tools/call"
181
+ expected_name = request.fetch("params").fetch("name")
182
+ header_mismatch!(request_id) unless HeaderField.single(supplied_name) == expected_name
183
+ elsif !supplied_name.nil?
184
+ header_mismatch!(request_id)
185
+ end
186
+ end
187
+
188
+ def validate_reserved_arguments!(request, request_id)
189
+ return unless request.fetch("method") == "tools/call"
190
+
191
+ arguments = request.fetch("params")["arguments"]
192
+ return unless arguments
193
+
194
+ invalid_params!(request_id, 200) if arguments.key?("server_context")
195
+ end
196
+
197
+ def parse_error!
198
+ failure!(400, -32700, "Parse error")
199
+ end
200
+
201
+ def invalid_request!
202
+ failure!(400, -32600, "Invalid Request")
203
+ end
204
+
205
+ def invalid_params!(request_id, http_status)
206
+ failure!(http_status, -32602, "Invalid params", request_id: request_id)
207
+ end
208
+
209
+ def header_mismatch!(request_id)
210
+ failure!(400, -32020, "HeaderMismatch", request_id: request_id)
211
+ end
212
+
213
+ def failure!(http_status, code, message, request_id: nil, data: nil)
214
+ raise Failure.new(
215
+ http_status: http_status,
216
+ code: code,
217
+ message: message,
218
+ request_id: request_id,
219
+ data: deep_copy_and_freeze(data)
220
+ )
221
+ end
222
+
223
+ def deep_copy_and_freeze(value)
224
+ JsonValues.deep_string_copy_and_freeze(value)
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ module MCP
5
+ # Framework-owned collaborators behind the public MCP surface (Endpoint,
6
+ # Tool, Registry, Result, Context). Nothing under this namespace is host
7
+ # API; shapes here may change in any release without notice.
8
+ module Internal
9
+ end
10
+ end
11
+ end