little_ghost 0.7.0 → 0.8.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +33 -5
  3. data/docs/guides/code_mode.md +2 -2
  4. data/docs/guides/getting_started.md +26 -1
  5. data/docs/guides/integrations.md +20 -161
  6. data/docs/guides/mcp.md +288 -0
  7. data/docs/guides/models_and_providers.md +21 -54
  8. data/docs/guides/production.md +14 -5
  9. data/docs/guides/providers.md +275 -0
  10. data/docs/guides/sandboxing.md +42 -7
  11. data/docs/guides/skills.md +1 -3
  12. data/docs/guides/tools.md +2 -0
  13. data/exe/little_ghost +12 -0
  14. data/lib/little_ghost/assembly.rb +11 -3
  15. data/lib/little_ghost/assembly_builder.rb +2 -2
  16. data/lib/little_ghost/cli.rb +138 -0
  17. data/lib/little_ghost/console.rb +17 -0
  18. data/lib/little_ghost/errors.rb +1 -1
  19. data/lib/little_ghost/generators/application_generator.rb +179 -0
  20. data/lib/little_ghost/generators/templates/application/Gemfile.tt +3 -0
  21. data/lib/little_ghost/generators/templates/application/README.md.tt +29 -0
  22. data/lib/little_ghost/generators/templates/application/app/agents/application_agent.rb.tt +5 -0
  23. data/lib/little_ghost/generators/templates/application/app/prompts/application/system.erb.tt +1 -0
  24. data/lib/little_ghost/generators/templates/application/bin/application.tt +10 -0
  25. data/lib/little_ghost/generators/templates/application/config/little_ghost.rb.tt +5 -0
  26. data/lib/little_ghost/generators/templates/application/gitignore.tt +2 -0
  27. data/lib/little_ghost/generators/templates/application/keep.tt +0 -0
  28. data/lib/little_ghost/mcp/client.rb +295 -606
  29. data/lib/little_ghost/mcp/toolset.rb +133 -95
  30. data/lib/little_ghost/mcp.rb +21 -3
  31. data/lib/little_ghost/model_resolver.rb +2 -0
  32. data/lib/little_ghost/models/catalog/models_dev_source.rb +4 -1
  33. data/lib/little_ghost/provider_registry.rb +1 -0
  34. data/lib/little_ghost/providers/lm_studio/catalog_source.rb +201 -0
  35. data/lib/little_ghost/providers/lm_studio.rb +52 -0
  36. data/lib/little_ghost/providers/openai.rb +3 -75
  37. data/lib/little_ghost/providers/openai_compatible.rb +78 -1
  38. data/lib/little_ghost/sandbox/environment_policy.rb +29 -4
  39. data/lib/little_ghost/sandbox/policy.rb +28 -6
  40. data/lib/little_ghost/sandboxes/unrestricted.rb +7 -3
  41. data/lib/little_ghost/skills/catalog.rb +13 -3
  42. data/lib/little_ghost/tool.rb +6 -1
  43. data/lib/little_ghost/version.rb +1 -1
  44. data/lib/little_ghost.rb +1 -0
  45. metadata +37 -7
  46. data/lib/little_ghost/mcp/types.rb +0 -216
@@ -1,494 +1,358 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
4
3
  require "base64"
4
+ require "digest"
5
5
  require "json"
6
- require "json_schemer"
7
- require "net/http"
8
- require "uri"
9
6
 
10
7
  module LittleGhost
11
- # MCP lets LittleGhost agents use tools published by Model Context Protocol
12
- # servers. Require +little_ghost/mcp+ to load the optional HTTP integration.
8
+ # Connects Agents to Tools published by Model Context Protocol servers through
9
+ # an application-provided official MCP Ruby client.
10
+ # See the {MCP guide}[rdoc-ref:docs/guides/mcp.md] to build a client, choose a
11
+ # transport, manage its lifecycle, and decide which operations and application
12
+ # data the server may access.
13
13
  module MCP
14
- # Model Context Protocol version negotiated by Client.
15
- PROTOCOL_VERSION = "2025-06-18"
16
-
17
- # HTTPTransport sends MCP JSON-RPC messages over Streamable HTTP. It applies
18
- # time and response-size limits and keeps the negotiated MCP session ID.
19
- #
20
- # === Connections and credentials
21
- #
22
- # HTTPS is required by default. +allow_insecure_http+ is only for a local
23
- # development endpoint. Scope caller-supplied
24
- # credential headers to the target server. Response bodies and negotiated
25
- # session IDs are validated before use.
26
- #
27
- # One transport instance retains one negotiated MCP session ID and sends it
28
- # with later requests. Use one transport and Client for one server and one
29
- # authenticated user or service identity; never share that pair across
30
- # tenants. LittleGhost does not send MCP session-termination DELETE requests,
31
- # so configure server-side expiry or send the cleanup request outside this
32
- # transport when the server requires explicit session termination.
33
- class HTTPTransport
34
- # Default upper bound for one MCP response body (10 MiB).
35
- DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024
36
- SESSION_ID_PATTERN = /\A[\x21-\x7e]{1,256}\z/ # :nodoc:
37
-
38
- # Configures time and response-size limits. +signer+, when
39
- # supplied, is called with each Net::HTTP request before it is sent.
40
- def initialize(url:, headers: {}, timeout: 60, signer: nil, allow_insecure_http: false,
41
- max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES)
42
- @uri = URI(url)
43
- unless %w[http https].include?(@uri.scheme) && @uri.host
44
- raise ConfigurationError, "MCP URL must be an HTTP(S) URL"
45
- end
46
- if @uri.scheme == "http" && !allow_insecure_http
47
- raise ConfigurationError, "MCP URL must use HTTPS unless allow_insecure_http is enabled"
48
- end
49
-
50
- @headers = headers.transform_keys(&:to_s).freeze
51
- @timeout = Float(timeout)
52
- raise ArgumentError, "timeout must be positive" unless @timeout.positive?
14
+ class CatalogValidatingTransport # :nodoc:
15
+ MAX_SCHEMA_DEPTH = 128
16
+ MAX_SCHEMA_NODES = 100_000
53
17
 
54
- @max_response_bytes = Integer(max_response_bytes)
55
- raise ArgumentError, "max_response_bytes must be positive" unless @max_response_bytes.positive?
18
+ attr_reader :transport
56
19
 
57
- @signer = signer
58
- @session_id = nil
20
+ def initialize(transport)
21
+ @transport = transport
22
+ @catalog_schema_nodes = 0
23
+ @catalog_mutex = Mutex.new
24
+ define_connect if transport.respond_to?(:connect)
59
25
  end
60
26
 
61
- # Sends one JSON-RPC payload. A RunContext supplies cancellation and a
62
- # deadline; without it the configured timeout applies.
63
- def send(payload, context: nil)
64
- return perform_send(payload, timeout: @timeout) unless context
65
-
66
- response = nil
67
- stream = Support::InterruptibleStream.new(
68
- cancellation_token: context.cancellation_token,
69
- deadline: context.deadline
70
- ) do |emit|
71
- emit.call(perform_send(payload, timeout: context.remaining_time(@timeout)))
72
- end
73
- stream.each { |value| response = value }
27
+ def send_request(request:, &sent)
28
+ method = request[:method] || request["method"]
29
+ response = transport.send_request(request:, &sent)
30
+ validate_catalog(response, reset: first_catalog_page?(request)) if method == "tools/list"
74
31
  response
32
+ rescue SystemStackError
33
+ raise unless method == "tools/list"
34
+
35
+ raise ::MCP::Client::ValidationError, "tools/list inputSchema exceeds the SDK nesting limit"
36
+ end
37
+
38
+ def method_missing(name, ...)
39
+ return super unless transport.respond_to?(name)
40
+
41
+ transport.public_send(name, ...)
42
+ end
43
+
44
+ def respond_to_missing?(name, include_private = false)
45
+ transport.respond_to?(name, include_private) || super
75
46
  end
76
47
 
77
48
  private
78
49
 
79
- def perform_send(payload, timeout:)
80
- request = Net::HTTP::Post.new(@uri)
81
- @headers.each { |name, value| request[name] = value }
82
- request["Accept"] ||= "application/json, text/event-stream"
83
- request["Content-Type"] ||= "application/json"
84
- request["MCP-Protocol-Version"] ||= PROTOCOL_VERSION
85
- request["Mcp-Session-Id"] = @session_id if @session_id
86
- request.body = JSON.generate(payload)
87
- @signer&.call(request)
88
-
89
- response = nil
90
- response_body = +""
91
- http(timeout).request(request) do |received|
92
- response = received
93
- validate_content_length!(received)
94
- received.read_body do |chunk|
95
- response_body << chunk
96
- raise ProtocolError, "MCP response exceeded #{@max_response_bytes} bytes" if response_body.bytesize > @max_response_bytes
50
+ def define_connect
51
+ accepts_mode = transport.method(:connect).parameters.any? do |kind, name|
52
+ %i[key keyreq].include?(kind) && name == :mode
53
+ end
54
+ if accepts_mode
55
+ define_singleton_method(:connect) do |client_info: nil, protocol_version: nil, capabilities: {}, mode: :legacy|
56
+ transport.connect(client_info:, protocol_version:, capabilities:, mode:)
57
+ end
58
+ else
59
+ define_singleton_method(:connect) do |client_info: nil, protocol_version: nil, capabilities: {}|
60
+ transport.connect(client_info:, protocol_version:, capabilities:)
97
61
  end
98
62
  end
99
- @session_id = validated_session_id(response["Mcp-Session-Id"]) if response["Mcp-Session-Id"]
100
- unless response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPAccepted)
101
- raise ProtocolError, "MCP request failed with HTTP #{response.code}"
63
+ end
64
+
65
+ def first_catalog_page?(request)
66
+ params = request[:params] || request["params"]
67
+ !params.is_a?(Hash) || !(params.key?(:cursor) || params.key?("cursor"))
68
+ end
69
+
70
+ def validate_catalog(response, reset:)
71
+ raise ::MCP::Client::ValidationError, "tools/list response must be an object" unless response.is_a?(Hash)
72
+ if response.key?("error")
73
+ error = response["error"]
74
+ unless error.is_a?(Hash) && error["code"].is_a?(Integer) && error["message"].is_a?(String)
75
+ raise ::MCP::Client::ValidationError, "tools/list error must be a JSON-RPC error object"
76
+ end
77
+ return
102
78
  end
103
79
 
104
- parse(response, response_body, payload[:id])
105
- rescue JSON::ParserError => error
106
- raise ProtocolError, "MCP returned invalid JSON: #{error.message}"
107
- rescue SystemCallError, SocketError, Timeout::Error => error
108
- raise ProviderError, "MCP transport failed: #{error.class}"
80
+ result = response["result"]
81
+ raise ::MCP::Client::ValidationError, "tools/list result must be an object" unless result.is_a?(Hash)
82
+
83
+ tools = result["tools"]
84
+ unless tools.is_a?(Array) && tools.all? { |tool| tool.is_a?(Hash) }
85
+ raise ::MCP::Client::ValidationError, "tools/list tools must be an array of objects"
86
+ end
87
+ @catalog_mutex.synchronize do
88
+ @catalog_schema_nodes = 0 if reset
89
+ tools.each do |tool|
90
+ @catalog_schema_nodes = validate_schema_complexity(
91
+ tool["inputSchema"],
92
+ nodes: @catalog_schema_nodes
93
+ )
94
+ end
95
+ end
96
+ cursor = result["nextCursor"]
97
+ unless cursor.nil? || cursor.is_a?(String)
98
+ raise ::MCP::Client::ValidationError, "tools/list nextCursor must be a string"
99
+ end
109
100
  end
110
101
 
111
- def http(timeout)
112
- Net::HTTP.new(@uri.host, @uri.port).tap do |client|
113
- client.use_ssl = @uri.scheme == "https"
114
- client.open_timeout = timeout
115
- client.read_timeout = timeout
116
- client.write_timeout = timeout
102
+ def validate_schema_complexity(schema, nodes:)
103
+ return nodes unless schema.is_a?(Hash) || schema.is_a?(Array)
104
+
105
+ pending = [[schema, 1]]
106
+ until pending.empty?
107
+ value, depth = pending.pop
108
+ if depth > MAX_SCHEMA_DEPTH
109
+ raise ::MCP::Client::ValidationError,
110
+ "tools/list inputSchema exceeds the #{MAX_SCHEMA_DEPTH}-level depth limit"
111
+ end
112
+ nodes += 1
113
+ if nodes > MAX_SCHEMA_NODES
114
+ raise ::MCP::Client::ValidationError,
115
+ "tools/list inputSchema exceeds the #{MAX_SCHEMA_NODES}-node limit"
116
+ end
117
+
118
+ children = value.is_a?(Hash) ? value.values : value
119
+ children.each do |child|
120
+ pending << [child, depth + 1] if child.is_a?(Hash) || child.is_a?(Array)
121
+ end
117
122
  end
123
+ nodes
118
124
  end
125
+ end
119
126
 
120
- def parse(response, body, expected_id)
121
- return {} if body.empty?
127
+ class Session # :nodoc:
128
+ attr_reader :client
122
129
 
123
- if response["Content-Type"].to_s.include?("text/event-stream")
124
- payloads = body.scan(/^data:\s*(.+)$/).flatten.reject { |data| data == "[DONE]" }
125
- raise ProtocolError, "MCP event stream contained no response" if payloads.empty?
130
+ def initialize(client)
131
+ @client = client
132
+ @closed = false
133
+ @unavailable = false
134
+ @close_mutex = Mutex.new
135
+ @request_mutex = Mutex.new if stdio?
136
+ end
126
137
 
127
- messages = payloads.map { |payload| JSON.parse(payload) }
128
- messages.reverse.find { |message| expected_id.nil? || message["id"] == expected_id } || {}
129
- else
130
- JSON.parse(body)
138
+ def request(context: nil)
139
+ ensure_available!
140
+ return yield unless @request_mutex
141
+
142
+ until @request_mutex.try_lock
143
+ context&.check!
144
+ sleep 0.01
131
145
  end
146
+ ensure_available!
147
+ yield
148
+ rescue ::MCP::CancelledError
149
+ invalidate! if stdio?
150
+ raise
151
+ ensure
152
+ @request_mutex&.unlock if @request_mutex&.owned?
132
153
  end
133
154
 
134
- def validate_content_length!(response)
135
- content_length = response["Content-Length"]
136
- if content_length && Integer(content_length, exception: false).to_i > @max_response_bytes
137
- raise ProtocolError, "MCP response exceeded #{@max_response_bytes} bytes"
155
+ def close
156
+ should_close = @close_mutex.synchronize do
157
+ next false if @closed
158
+
159
+ @closed = true
160
+ true
138
161
  end
162
+ client.transport.close if should_close && client.transport.respond_to?(:close)
139
163
  end
140
164
 
141
- def validated_session_id(value)
142
- return value if SESSION_ID_PATTERN.match?(value)
165
+ private
143
166
 
144
- raise ProtocolError, "MCP server returned an invalid session ID"
167
+ def invalidate!
168
+ @close_mutex.synchronize { @unavailable = true }
169
+ close
145
170
  end
146
- end
147
171
 
148
- # Client is the lower-level interface for loading tools from an MCP server.
149
- # Most Agents can declare a reusable Toolset instead. Use Client directly
150
- # when an application needs a custom transport.
151
- #
152
- # transport = LittleGhost::MCP::HTTPTransport.new(url: "https://mcp.example/rpc")
153
- # client = LittleGhost::MCP::Client.new(transport:)
154
- # client.tools.map(&:tool_name) # => ["search", "fetch"]
155
- #
156
- # Tool names are normalized and checked for collisions. LittleGhost limits
157
- # catalog size, schema complexity, and returned media before creating Tool
158
- # classes. +tool_mapper+ and +result_mapper+ use the same Definition,
159
- # Result, and Call values as Toolset.
160
- #
161
- # The server chooses its definitions and results. LittleGhost checks their
162
- # structure before creating Tools, but the application still chooses which
163
- # servers and operations an Agent may use.
164
- #
165
- # A client and its transport represent one authenticated server session.
166
- # Create a separate pair for each authenticated user or service identity. Protocol
167
- # initialization and subsequent requests through one client are serialized;
168
- # do not share its transport with another client.
169
- class Client
170
- PreparedDefinition = Data.define(:definition, :input_schema, :output_schemer) # :nodoc:
171
- MAX_TOOL_NAME_LENGTH = 64 # :nodoc:
172
- ALIAS_DIGEST_LENGTH = 12 # :nodoc:
173
- DEFAULT_MAX_TOOLS = 1_000 # :nodoc:
174
- DEFAULT_MAX_PAGES = 100 # :nodoc:
175
- DEFAULT_MAX_DISCOVERY_BYTES = 10 * 1024 * 1024 # :nodoc:
176
- DEFAULT_MAX_DISCOVERY_NODES = 100_000 # :nodoc:
177
- DEFAULT_MAX_CURSOR_BYTES = 16 * 1024 # :nodoc:
178
- DEFAULT_MAX_DEFINITION_DEPTH = 64 # :nodoc:
179
- DEFAULT_MAX_DEFINITION_NODES = 10_000 # :nodoc:
180
- DEFAULT_MAX_SCHEMA_PATTERNS = 1_000 # :nodoc:
181
- DEFAULT_MAX_SCHEMA_PATTERN_BYTES = 1024 * 1024 # :nodoc:
182
- DEFAULT_MAX_SCHEMA_PATTERN_SOURCE_BYTES = 64 * 1024 # :nodoc:
183
- DEFAULT_MAX_IMAGES = 20 # :nodoc:
184
- DEFAULT_MAX_IMAGE_BYTES = 16 * 1024 * 1024 # :nodoc:
185
- DEFAULT_MAX_TOTAL_IMAGE_BYTES = 64 * 1024 * 1024 # :nodoc:
186
- ECMA_REGEXP_RESOLVER = lambda do |pattern| # :nodoc:
187
- source = JSONSchemer::EcmaRegexp.ruby_equivalent(pattern)
188
- Regexp.new(source, timeout: Tool::SchemaValidator::REGEXP_TIMEOUT)
172
+ def ensure_available!
173
+ unavailable = @close_mutex.synchronize { @unavailable }
174
+ raise ProviderError, "MCP stdio session is unavailable after cancellation" if unavailable
189
175
  end
190
176
 
191
- # Uses a transport that responds to +send+. +tool_mapper+ receives each
192
- # generated Tool class and may return a configured class or nil.
193
- # +result_mapper+ receives Result and Call values. Catalog, schema, and
194
- # media limits use fixed framework defaults.
195
- def initialize(
196
- transport:,
197
- name: "mcp",
198
- tool_mapper: nil,
199
- result_mapper: nil
200
- )
201
- validate_callback(tool_mapper, :tool_mapper)
202
- validate_callback(result_mapper, :result_mapper)
177
+ def stdio?
178
+ transport = client.transport
179
+ transport = transport.transport if transport.is_a?(CatalogValidatingTransport)
180
+ defined?(::MCP::Client::Stdio) && transport.is_a?(::MCP::Client::Stdio)
181
+ end
182
+ end
203
183
 
204
- @transport = transport
184
+ class Adapter # :nodoc:
185
+ MAX_TOOL_NAME_LENGTH = 64
186
+ ALIAS_DIGEST_LENGTH = 12
187
+ MAX_TOOLS = 1_000
188
+ MAX_TOOL_PAGES = 100
189
+ CONTEXT_POLL_INTERVAL = 0.05
190
+
191
+ def initialize(client:, session:, name: "mcp", tool_mapper: nil, result_mapper: nil)
192
+ @client = client
193
+ @session = session
205
194
  @name = String(name)
206
195
  @tool_mapper = tool_mapper
207
196
  @result_mapper = result_mapper
208
- @max_tools = DEFAULT_MAX_TOOLS
209
- @max_pages = DEFAULT_MAX_PAGES
210
- @max_discovery_bytes = DEFAULT_MAX_DISCOVERY_BYTES
211
- @max_discovery_nodes = DEFAULT_MAX_DISCOVERY_NODES
212
- @max_definition_depth = DEFAULT_MAX_DEFINITION_DEPTH
213
- @max_definition_nodes = DEFAULT_MAX_DEFINITION_NODES
214
- @max_images = DEFAULT_MAX_IMAGES
215
- @max_image_bytes = DEFAULT_MAX_IMAGE_BYTES
216
- @max_total_image_bytes = DEFAULT_MAX_TOTAL_IMAGE_BYTES
217
- @request_id = 0
218
- @mutex = Mutex.new
219
- @initialization_mutex = Mutex.new
220
- @definitions_mutex = Mutex.new
221
- @definitions_by_name = {}
222
- @initialized = false
223
197
  end
224
198
 
225
- # Negotiates the protocol when needed, then provides Tool classes for the
226
- # server's current definitions.
227
199
  def tools(context: nil, binding: Tool::Binding.new)
228
200
  context&.check!
229
- ensure_initialized(context:)
230
- definitions = list_tool_definitions(context:).map do |raw|
201
+ generated = discover_tools(context:).filter_map do |mcp_tool|
231
202
  context&.check!
232
- build_definition(raw, context:).tap { context&.check! }
203
+ build_tool(mcp_tool, binding:)
233
204
  end
234
- tools = definitions.filter_map do |definition|
235
- context&.check!
236
- build_tool(definition, binding:).tap { context&.check! }
237
- end
238
- index_tools!(tools, context:)
205
+ validate_tool_names!(generated, context:)
206
+ generated.freeze
207
+ rescue ::MCP::CancelledError
239
208
  context&.check!
240
- tools.freeze
241
- end
242
-
243
- # Calls a generated or named Tool and returns the common Tool execution
244
- # result. Names discovered through #tools resolve to their stored
245
- # Definition; an undiscovered name is treated as a source name.
246
- def call(name, arguments, context: nil, binding: Tool::Binding.new)
209
+ raise CancelledError, "The MCP request was cancelled"
210
+ rescue ::MCP::Client::ServerError => error
211
+ raise ToolError, error.message.to_s.empty? ? "MCP request failed" : error.message
212
+ rescue ::MCP::Client::PaginationLimitError => error
213
+ raise ProtocolError, "MCP tools/list exceeded the pagination limit: #{error.message}"
214
+ rescue ::MCP::Client::ValidationError, ::MCP::Client::InputRequiredError => error
215
+ raise ProtocolError, "MCP client rejected the response: #{error.message}"
216
+ rescue ::MCP::Client::RequestHandlerError => error
217
+ raise ProviderError, "MCP transport failed (#{error.error_type})"
218
+ end
219
+
220
+ def call(mcp_tool, arguments, context:, binding:)
247
221
  context&.check!
248
- ensure_initialized(context:)
249
- prepared = if name.is_a?(PreparedDefinition)
250
- name
251
- elsif name.is_a?(Definition)
252
- prepare_definition(name, context:)
253
- else
254
- definition_for_call(name, context:)
222
+ response = request(context:) do |cancellation|
223
+ @client.call_tool(name: mcp_tool.name, arguments:, cancellation:)
255
224
  end
256
- definition = prepared.definition
257
- call_value = Call.new(definition:, arguments:, context:, binding:)
258
- raw = request(
259
- "tools/call",
260
- {name: definition.source_name, arguments: call_value.arguments.to_h},
261
- context:
262
- )
263
- result = build_result(raw, prepared:)
264
- context&.check!
225
+ result = response.is_a?(Hash) && response["result"]
226
+ raise ProtocolError, "MCP tools/call did not return a result object" unless result.is_a?(Hash)
227
+
228
+ content = result.fetch("content", [])
229
+ validate_content!(content)
230
+ value = default_value(result, content:)
265
231
  mapped = if @result_mapper
266
- @result_mapper.call(result, call: call_value, binding:)
232
+ @result_mapper.call(value, result:, mcp_tool:, arguments:, binding:)
267
233
  else
268
- default_value(result)
234
+ value
269
235
  end
270
236
  context&.check!
271
- tool_result(mapped, protocol_result: result)
237
+ tool_result(mapped, result:, content:)
238
+ rescue ::MCP::CancelledError
239
+ context&.check!
240
+ raise CancelledError, "The MCP request was cancelled"
241
+ rescue ::MCP::Client::ServerError => error
242
+ raise ToolError, error.message.to_s.empty? ? "MCP request failed" : error.message
243
+ rescue ::MCP::Client::ValidationError, ::MCP::Client::InputRequiredError => error
244
+ raise ProtocolError, "MCP client rejected the response: #{error.message}"
245
+ rescue ::MCP::Client::RequestHandlerError => error
246
+ raise ProviderError, "MCP transport failed (#{error.error_type})"
272
247
  end
273
248
 
274
249
  private
275
250
 
276
- def ensure_initialized(context:)
277
- @initialization_mutex.synchronize do
278
- initialize_protocol(context:) unless @initialized
279
- end
280
- end
281
-
282
- def initialize_protocol(context:)
283
- result = request("initialize", {
284
- protocolVersion: PROTOCOL_VERSION,
285
- capabilities: {},
286
- clientInfo: {name: "little_ghost", version: LittleGhost::VERSION}
287
- }, context:)
288
- unless result["protocolVersion"].to_s == PROTOCOL_VERSION
289
- raise ProtocolError, "MCP server did not negotiate protocol #{PROTOCOL_VERSION}"
290
- end
291
- unless result["capabilities"].is_a?(Hash)
292
- raise ProtocolError, "MCP initialize result must include capabilities"
293
- end
294
- server_info = result["serverInfo"]
295
- unless server_info.is_a?(Hash) &&
296
- server_info["name"].is_a?(String) && !server_info["name"].empty? &&
297
- server_info["version"].is_a?(String) && !server_info["version"].empty?
298
- raise ProtocolError, "MCP initialize result must include serverInfo name and version"
299
- end
251
+ def discover_tools(context:)
252
+ request(context:) do |cancellation|
253
+ cursor = nil
254
+ discovered = []
255
+ pages = 0
256
+ loop do
257
+ pages += 1
258
+ if pages > MAX_TOOL_PAGES
259
+ raise ProtocolError, "MCP tools/list exceeded the #{MAX_TOOL_PAGES}-page limit"
260
+ end
261
+ page = @client.list_tools(cursor:, cancellation:)
262
+ if discovered.length + page.tools.length > MAX_TOOLS
263
+ raise ProtocolError, "MCP tools/list exceeded the #{MAX_TOOLS}-tool limit"
264
+ end
300
265
 
301
- notify("notifications/initialized", context:)
302
- @initialized = true
303
- end
266
+ discovered.concat(page.tools)
267
+ cursor = page.next_cursor
268
+ break unless cursor
304
269
 
305
- def request(method, params = {}, context: nil)
306
- context&.check!
307
- expected_id, response = @mutex.synchronize do
308
- @request_id += 1
309
- [@request_id, @transport.send(
310
- {jsonrpc: "2.0", id: @request_id, method: method, params: params},
311
- context:
312
- )]
313
- end
314
- context&.check!
315
- raise ProtocolError, "MCP response must be an object" unless response.is_a?(Hash)
316
- raise ProtocolError, "MCP response must use JSON-RPC 2.0" unless response["jsonrpc"] == "2.0"
317
- unless response.key?("id") && response["id"] == expected_id
318
- raise ProtocolError, "MCP response ID did not match its request"
319
- end
320
- has_result = response.key?("result")
321
- has_error = response.key?("error")
322
- unless has_result ^ has_error
323
- raise ProtocolError, "MCP response must include exactly one of result or error"
324
- end
325
- if has_error
326
- error = response["error"]
327
- raise ProtocolError, "MCP response error must be an object" unless error.is_a?(Hash)
328
- unless error["code"].is_a?(Integer) && error["message"].is_a?(String)
329
- raise ProtocolError, "MCP response error must include an integer code and string message"
270
+ context&.check!
330
271
  end
331
-
332
- message = error["message"]
333
- raise ToolError, message.empty? ? "MCP request failed" : message
272
+ discovered
334
273
  end
335
- result = response["result"]
336
- raise ProtocolError, "MCP response did not include a result object" unless result.is_a?(Hash)
337
-
338
- result
339
274
  end
340
275
 
341
- def notify(method, params = {}, context: nil)
342
- context&.check!
343
- @transport.send({jsonrpc: "2.0", method: method, params: params}, context:).tap do
344
- context&.check!
276
+ def request(context:)
277
+ @session.request(context:) do
278
+ request_with_cancellation(context:) { |cancellation| yield(cancellation) }
345
279
  end
346
280
  end
347
281
 
348
- def list_tool_definitions(context:)
349
- definitions = []
350
- discovery_bytes = 0
351
- discovery_nodes = 0
352
- cursor = nil
353
- seen_cursors = {}
354
- pages = 0
355
- loop do
356
- context&.check!
357
- pages += 1
358
- raise ProtocolError, "MCP tools/list exceeded #{@max_pages} pages" if pages > @max_pages
359
-
360
- result = request("tools/list", cursor ? {cursor: cursor} : {}, context:)
361
- context&.check!
362
- tools = result.fetch("tools", [])
363
- raise ProtocolError, "MCP tools/list tools must be an array" unless tools.is_a?(Array)
282
+ def request_with_cancellation(context:)
283
+ return yield(nil) unless context
364
284
 
365
- tools.each do |definition|
366
- context&.check!
367
- bytes, nodes = definition_complexity(definition, context:)
368
- discovery_bytes += bytes
369
- discovery_nodes += nodes
370
- if discovery_bytes > @max_discovery_bytes
371
- raise ProtocolError, "MCP tools/list exceeded the #{@max_discovery_bytes}-byte definition limit"
285
+ context.check!
286
+ cancellation = ::MCP::Cancellation.new
287
+ finished = false
288
+ watcher_mutex = Mutex.new
289
+ watcher_condition = ConditionVariable.new
290
+ watcher = Thread.new do
291
+ loop do
292
+ done = watcher_mutex.synchronize do
293
+ watcher_condition.wait(watcher_mutex, CONTEXT_POLL_INTERVAL) unless finished
294
+ finished
372
295
  end
373
- if discovery_nodes > @max_discovery_nodes
374
- raise ProtocolError, "MCP tools/list exceeded the #{@max_discovery_nodes}-node definition limit"
296
+ break if done
297
+ if context.cancellation_token.cancelled?
298
+ cancellation.cancel(reason: "LittleGhost run cancelled")
299
+ break
300
+ end
301
+ if context.deadline && Time.now >= context.deadline
302
+ cancellation.cancel(reason: "LittleGhost run deadline reached")
303
+ break
375
304
  end
376
305
  end
377
- definitions.concat(tools)
378
- raise ProtocolError, "MCP tools/list exceeded #{@max_tools} tools" if definitions.length > @max_tools
379
- cursor = result["nextCursor"]
380
- break if cursor.nil?
381
- unless cursor.is_a?(String) && !cursor.empty?
382
- raise ProtocolError, "MCP tools/list nextCursor must be a non-empty string"
383
- end
384
- if cursor.bytesize > DEFAULT_MAX_CURSOR_BYTES
385
- raise ProtocolError, "MCP tools/list nextCursor exceeded the #{DEFAULT_MAX_CURSOR_BYTES}-byte limit"
386
- end
387
- discovery_bytes += cursor.bytesize
388
- if discovery_bytes > @max_discovery_bytes
389
- raise ProtocolError, "MCP tools/list exceeded the #{@max_discovery_bytes}-byte definition limit"
306
+ end
307
+ watcher.report_on_exception = false
308
+ yield(cancellation).tap { context.check! }
309
+ ensure
310
+ if watcher
311
+ watcher_mutex.synchronize do
312
+ finished = true
313
+ watcher_condition.broadcast
390
314
  end
391
-
392
- raise ProtocolError, "MCP tools/list repeated a pagination cursor" if seen_cursors[cursor]
393
-
394
- seen_cursors[cursor] = true
315
+ watcher.join
395
316
  end
396
- context&.check!
397
- definitions
398
317
  end
399
318
 
400
- def build_tool(prepared, binding:)
401
- definition = prepared.definition
402
- client = self
319
+ def build_tool(mcp_tool, binding:)
320
+ validate_mcp_tool!(mcp_tool)
321
+ adapter = self
322
+ session = @session
403
323
  tool_class = Class.new(Tool) do
404
- tool_name definition.name
405
- description definition.description
406
- input_schema prepared.input_schema
324
+ tool_name adapter.send(:safe_name, mcp_tool.name)
325
+ description adapter.send(:description_for, mcp_tool)
326
+ input_schema mcp_tool.input_schema || {"type" => "object"}
327
+ self.validate_input_schema_value = false
407
328
 
408
- define_singleton_method(:mcp_definition) { definition }
329
+ define_singleton_method(:mcp_tool) { mcp_tool }
409
330
  define_method(:call) do |input|
410
- client.call(prepared, input, context:, binding: self.binding)
331
+ adapter.call(mcp_tool, input, context:, binding: self.binding)
411
332
  end
333
+ define_method(:close) { session.close }
412
334
  end
413
- tool_class.instance_variable_set(:@little_ghost_mcp_prepared_definition, prepared)
414
335
  return tool_class unless @tool_mapper
415
336
 
416
- mapped = @tool_mapper.call(tool_class, definition:, binding:)
337
+ mapped = @tool_mapper.call(tool_class, mcp_tool:, binding:)
417
338
  return if mapped.nil?
418
339
  unless mapped.is_a?(Class) && mapped <= tool_class
419
340
  raise ConfigurationError, "MCP tool mapper must return the generated Tool class, a subclass, or nil"
420
341
  end
421
342
 
422
- mapped.instance_variable_set(:@little_ghost_mcp_prepared_definition, prepared)
423
343
  mapped
424
344
  end
425
345
 
426
- def build_definition(raw, context: nil)
427
- context&.check!
428
- source_name = definition_name(raw)
429
- definition = Definition.new(
430
- source_name:,
431
- name: safe_name(source_name),
432
- description: present_description(raw["description"]),
433
- input_schema: raw.fetch("inputSchema", {type: "object"}),
434
- output_schema: raw["outputSchema"],
435
- annotations: raw.fetch("annotations", {}),
436
- title: raw["title"],
437
- metadata: raw.fetch("_meta", {}),
438
- raw:
439
- )
440
- prepare_definition(definition, context:)
441
- rescue SystemStackError
442
- raise ProtocolError, "MCP tool definition exceeds the supported nesting limit"
443
- end
444
-
445
- def definition_complexity(definition, context:)
446
- nodes = 0
447
- bytes = 0
448
- stack = [[definition, 1]]
449
- until stack.empty?
450
- value, depth = stack.pop
451
- raise ProtocolError, "MCP tool definition exceeds the #{@max_definition_depth}-level depth limit" if depth > @max_definition_depth
452
-
453
- nodes += 1
454
- if nodes > @max_definition_nodes
455
- raise ProtocolError, "MCP tool definition exceeds the #{@max_definition_nodes}-node limit"
456
- end
457
- context&.check! if (nodes % 1_000).zero?
458
- case value
459
- when Hash
460
- bytes += 2
461
- value.each do |key, child|
462
- raise ProtocolError, "MCP tool definition keys must be strings" unless key.is_a?(String)
463
-
464
- bytes += key.bytesize + 3
465
- stack << [child, depth + 1]
466
- end
467
- when Array
468
- bytes += 2 + value.length
469
- value.each { |child| stack << [child, depth + 1] }
470
- when String
471
- bytes += value.bytesize + 2
472
- when Numeric
473
- bytes += value.to_s.bytesize
474
- when true, false
475
- bytes += value ? 4 : 5
476
- when nil
477
- bytes += 4
478
- else
479
- raise ProtocolError, "MCP tool definition contains a non-JSON value"
480
- end
346
+ def validate_mcp_tool!(mcp_tool)
347
+ unless mcp_tool.is_a?(::MCP::Client::Tool)
348
+ raise ProtocolError, "MCP tools/list returned an unsupported tool value"
349
+ end
350
+ unless mcp_tool.name.is_a?(String) && !mcp_tool.name.empty?
351
+ raise ProtocolError, "MCP tool definition must include a name"
352
+ end
353
+ unless mcp_tool.input_schema.nil? || mcp_tool.input_schema.is_a?(Hash)
354
+ raise ProtocolError, "MCP tool inputSchema must be an object"
481
355
  end
482
- [bytes, nodes]
483
- end
484
-
485
- def definition_name(definition)
486
- raise ProtocolError, "MCP tool definition must be an object" unless definition.is_a?(Hash)
487
-
488
- name = definition["name"]
489
- raise ProtocolError, "MCP tool definition must include a name" unless name.is_a?(String) && !name.empty?
490
-
491
- name
492
356
  end
493
357
 
494
358
  def safe_name(name)
@@ -501,54 +365,28 @@ module LittleGhost
501
365
  "#{normalized[0, prefix_length]}_#{digest}"
502
366
  end
503
367
 
504
- def present_description(description)
505
- value = description.to_s
506
- value.empty? ? "MCP tool from #{@name}" : value
368
+ def description_for(mcp_tool)
369
+ description = mcp_tool.description.to_s
370
+ description.empty? ? "MCP tool from #{@name}" : description
507
371
  end
508
372
 
509
- def index_tools!(tools, context: nil)
373
+ def validate_tool_names!(tools, context:)
510
374
  indexed = {}
511
375
  tools.each do |tool_class|
512
376
  context&.check!
513
- definition = tool_class.mcp_definition
514
377
  name = tool_class.tool_name
515
378
  existing = indexed[name]
516
- if existing && existing.definition.source_name != definition.source_name
379
+ if existing && existing.mcp_tool.name != tool_class.mcp_tool.name
517
380
  raise ConfigurationError,
518
- "MCP tools #{existing.definition.source_name.inspect} and #{definition.source_name.inspect} map to #{name.inspect}"
381
+ "MCP tools #{existing.mcp_tool.name.inspect} and #{tool_class.mcp_tool.name.inspect} map to #{name.inspect}"
519
382
  end
520
- prepared = tool_class.instance_variable_get(:@little_ghost_mcp_prepared_definition)
521
- prepared ||= prepare_definition(definition, context:)
522
- indexed[name] = prepared
383
+ indexed[name] = tool_class
523
384
  end
524
- @definitions_mutex.synchronize { @definitions_by_name = indexed.freeze }
525
385
  end
526
386
 
527
- def definition_for_call(name, context: nil)
528
- value = String(name)
529
- @definitions_mutex.synchronize { @definitions_by_name[value] } || prepare_definition(Definition.new(
530
- source_name: value,
531
- name: safe_name(value),
532
- description: "MCP tool from #{@name}",
533
- input_schema: {type: "object"},
534
- raw: {name: value}
535
- ), context:)
536
- end
387
+ def validate_content!(content)
388
+ raise ProtocolError, "MCP tool result content must be an array" unless content.is_a?(Array)
537
389
 
538
- def build_result(raw, prepared:)
539
- result = Result.new(
540
- content: raw.fetch("content", []),
541
- structured_content: raw["structuredContent"],
542
- error: raw["isError"],
543
- metadata: raw.fetch("_meta", {}),
544
- raw:
545
- )
546
- validate_result_blocks!(result.content)
547
- validate_structured_content!(result, prepared:)
548
- result
549
- end
550
-
551
- def validate_result_blocks!(content)
552
390
  content.each do |block|
553
391
  raise ProtocolError, "MCP tool result content blocks must be objects" unless block.is_a?(Hash)
554
392
  raise ProtocolError, "MCP tool result content blocks must include a type" unless block["type"].is_a?(String)
@@ -558,132 +396,34 @@ module LittleGhost
558
396
  end
559
397
  end
560
398
 
561
- def validate_structured_content!(result, prepared:)
562
- definition = prepared.definition
563
- schema = definition.output_schema
564
- return unless schema
565
- raise ProtocolError, "MCP tool result omitted structuredContent required by outputSchema" unless result.structured_content
566
-
567
- errors = prepared.output_schemer.validate(result.structured_content.to_h).take(10)
568
- return if errors.empty?
569
-
570
- details = errors.filter_map { |error| error["error"] }.join("; ")
571
- raise ProtocolError, "MCP structuredContent did not match outputSchema: #{details}"
572
- rescue JSONSchemer::UnknownRef, JSONSchemer::InvalidRefResolution, JSONSchemer::InvalidRefPointer
573
- raise ProtocolError, "MCP outputSchema contains an unresolved reference"
574
- rescue Regexp::TimeoutError
575
- raise ProtocolError, "MCP structuredContent exceeded the outputSchema pattern time limit"
576
- end
577
-
578
- def prepare_definition(definition, context: nil)
579
- context&.check!
580
- input_schema = normalized_input_schema(definition.input_schema, context:)
581
- schema = definition.output_schema
582
- return PreparedDefinition.new(definition:, input_schema:, output_schemer: nil) unless schema
583
-
584
- schema_hash = schema.to_h
585
- schema_patterns(schema_hash, field: "outputSchema", context:)
586
- context&.check!
587
- errors = JSONSchemer.validate_schema(schema_hash).take(10)
588
- unless errors.empty?
589
- details = errors.filter_map { |error| error["error"] }.join("; ")
590
- raise ProtocolError, "MCP tool outputSchema is invalid: #{details}"
399
+ def tool_result(mapped, result:, content:)
400
+ error = result["isError"]
401
+ unless error.nil? || error == true || error == false
402
+ raise ProtocolError, "MCP tool result isError must be boolean"
591
403
  end
592
404
 
593
- schemer = JSONSchemer.schema(schema_hash, regexp_resolver: ECMA_REGEXP_RESOLVER)
594
- context&.check!
595
- PreparedDefinition.new(definition:, input_schema:, output_schemer: schemer)
596
- rescue JSONSchemer::UnknownRef, JSONSchemer::InvalidRefResolution, JSONSchemer::InvalidRefPointer
597
- raise ProtocolError, "MCP tool outputSchema contains an unresolved reference"
598
- end
599
-
600
- def normalized_input_schema(schema, context: nil)
601
- normalized = JSON.parse(JSON.generate(schema.to_h))
602
- schema_patterns(normalized, field: "inputSchema", context:).each do |container, key, regexp|
603
- context&.check!
604
- container[key] = regexp.source if container
605
- end
606
- normalized
607
- rescue JSON::GeneratorError, JSON::ParserError
608
- raise ProtocolError, "MCP tool inputSchema is not valid JSON"
609
- end
610
-
611
- def schema_patterns(schema, field:, context: nil)
612
- patterns = []
613
- total_bytes = 0
614
- stack = [schema]
615
- until stack.empty?
616
- context&.check!
617
- value = stack.pop
618
- case value
619
- when Hash
620
- if value.key?("pattern")
621
- pattern = value["pattern"]
622
- unless pattern.is_a?(String)
623
- raise ProtocolError, "MCP tool #{field} pattern must be a string"
624
- end
625
- patterns << [value, "pattern", pattern]
626
- end
627
- pattern_properties = value["patternProperties"]
628
- if pattern_properties
629
- unless pattern_properties.is_a?(Hash)
630
- raise ProtocolError, "MCP tool #{field} patternProperties must be an object"
631
- end
632
- pattern_properties.each_key { |pattern| patterns << [nil, nil, pattern] }
633
- end
634
- value.each_value { |child| stack << child }
635
- when Array
636
- value.each { |child| stack << child }
637
- end
638
- end
639
- if patterns.length > DEFAULT_MAX_SCHEMA_PATTERNS
640
- raise ProtocolError, "MCP tool #{field} exceeded the #{DEFAULT_MAX_SCHEMA_PATTERNS}-pattern limit"
641
- end
642
-
643
- patterns.map do |container, key, pattern|
644
- context&.check!
645
- unless pattern.is_a?(String)
646
- raise ProtocolError, "MCP tool #{field} pattern must be a string"
647
- end
648
- if pattern.bytesize > DEFAULT_MAX_SCHEMA_PATTERN_SOURCE_BYTES
649
- raise ProtocolError,
650
- "MCP tool #{field} pattern exceeded the #{DEFAULT_MAX_SCHEMA_PATTERN_SOURCE_BYTES}-byte limit"
651
- end
652
- total_bytes += pattern.bytesize
653
- if total_bytes > DEFAULT_MAX_SCHEMA_PATTERN_BYTES
654
- raise ProtocolError,
655
- "MCP tool #{field} patterns exceeded the #{DEFAULT_MAX_SCHEMA_PATTERN_BYTES}-byte total limit"
656
- end
657
- [container, key, ECMA_REGEXP_RESOLVER.call(pattern)]
658
- end
659
- rescue JSONSchemer::InvalidEcmaRegexp, RegexpError
660
- raise ProtocolError, "MCP tool #{field} contains an invalid pattern"
661
- end
662
-
663
- def tool_result(mapped, protocol_result:)
664
- media = image_artifacts(protocol_result.content)
665
- mapped = default_value(mapped) if mapped.is_a?(Result)
405
+ media = image_artifacts(content)
666
406
  value, artifacts = if mapped.is_a?(Tool::Result)
667
407
  [mapped.value, [*mapped.artifacts, *media]]
668
408
  else
669
409
  [mapped, media]
670
410
  end
671
- raise ToolError, serialize_value(value) if protocol_result.error?
411
+ raise ToolError, serialize_value(value) if error
672
412
 
673
413
  Tool::Result.new(value:, artifacts:)
674
414
  end
675
415
 
676
- def default_value(result)
677
- return result.structured_content if result.structured_content
416
+ def default_value(result, content:)
417
+ return result["structuredContent"] if result.key?("structuredContent")
678
418
 
679
- text = result.content.filter_map { |block| block["text"] if block["type"] == "text" }.join("\n")
419
+ text = content.filter_map { |block| block["text"] if block["type"] == "text" }.join("\n")
680
420
  return text unless text.empty?
681
421
 
682
- visible = result.content.reject { |block| block["type"] == "image" }
422
+ visible = content.reject { |block| block["type"] == "image" }
683
423
  return visible unless visible.empty?
684
424
 
685
- ImmutableValue.mapping({
686
- "images" => result.content.filter_map do |block|
425
+ {
426
+ "images" => content.filter_map do |block|
687
427
  next unless block["type"] == "image"
688
428
 
689
429
  {
@@ -692,43 +432,32 @@ module LittleGhost
692
432
  "bytes" => strict_base64_bytesize(block["data"])
693
433
  }.compact
694
434
  end
695
- }, field: "image result")
435
+ }
436
+ rescue ArgumentError
437
+ raise ProtocolError, "MCP image content data must use strict base64"
696
438
  end
697
439
 
698
440
  def image_artifacts(content)
699
- images = content.select { |block| block["type"] == "image" }
700
- if images.length > @max_images
701
- raise ProtocolError, "MCP image content exceeds the #{@max_images}-image limit"
702
- end
441
+ content.filter_map do |block|
442
+ next unless block["type"] == "image"
703
443
 
704
- total_bytes = 0
705
- images.map do |block|
706
444
  data = block["data"]
707
445
  media_type = block["mimeType"]
708
446
  unless data.is_a?(String) && media_type.is_a?(String) && media_type.start_with?("image/")
709
447
  raise ProtocolError, "MCP image content is invalid"
710
448
  end
711
- decoded_bytes = strict_base64_bytesize(data)
712
- if decoded_bytes > @max_image_bytes
713
- raise ProtocolError, "MCP image content exceeds the #{@max_image_bytes}-byte per-image limit"
714
- end
715
- total_bytes += decoded_bytes
716
- if total_bytes > @max_total_image_bytes
717
- raise ProtocolError, "MCP image content exceeds the #{@max_total_image_bytes}-byte total limit"
718
- end
719
- Artifact.new(
720
- data: Base64.strict_decode64(data),
721
- media_type:,
722
- name: block["name"]
723
- )
449
+ strict_base64_bytesize(data)
450
+ Artifact.new(data: Base64.strict_decode64(data), media_type:, name: block["name"])
724
451
  rescue ArgumentError
725
452
  raise ProtocolError, "MCP image content data must use strict base64"
726
453
  end
727
454
  end
728
455
 
729
456
  def strict_base64_bytesize(data)
730
- raise ArgumentError unless data.bytesize % 4 == 0
731
- raise ArgumentError unless /\A(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?\z/.match?(data)
457
+ raise ArgumentError unless data.is_a?(String) && (data.bytesize % 4).zero?
458
+ unless /\A(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?\z/.match?(data)
459
+ raise ArgumentError
460
+ end
732
461
 
733
462
  padding = if data.end_with?("==")
734
463
  2
@@ -750,46 +479,6 @@ module LittleGhost
750
479
  rescue JSON::GeneratorError
751
480
  raise ToolError, "MCP result transformation could not be serialized"
752
481
  end
753
-
754
- def validate_callback(value, name)
755
- return unless value
756
- raise ArgumentError, "#{name} must respond to call" unless value.respond_to?(:call)
757
- end
758
-
759
- def positive_integer(value, name)
760
- integer = Integer(value)
761
- raise ArgumentError, "#{name} must be positive" unless integer.positive?
762
-
763
- integer
764
- end
765
- end
766
-
767
- # SigV4Signer adds AWS Signature Version 4 authentication to MCP requests.
768
- # Requires the application-provided +aws-sigv4+ gem and uses its normal AWS
769
- # credentials-provider chain unless one is supplied explicitly.
770
- class SigV4Signer
771
- # Configures signing for +service+ and +region+.
772
- def initialize(service:, region:, credentials_provider: nil)
773
- require "aws-sigv4"
774
- @signer = Aws::Sigv4::Signer.new(
775
- service: service,
776
- region: region,
777
- credentials_provider: credentials_provider
778
- )
779
- rescue LoadError
780
- raise ConfigurationError, "MCP SigV4 signing requires the optional aws-sigv4 gem"
781
- end
782
-
783
- # Signs +request+ in place immediately before transport.
784
- def call(request)
785
- signature = @signer.sign_request(
786
- http_method: request.method,
787
- url: request.uri,
788
- headers: request.to_hash.transform_values { |values| Array(values).join(",") },
789
- body: request.body
790
- )
791
- signature.headers.each { |name, value| request[name] = value }
792
- end
793
482
  end
794
483
  end
795
484
  end