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