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.
- checksums.yaml +4 -4
- data/README.md +33 -5
- data/docs/guides/code_mode.md +2 -2
- data/docs/guides/getting_started.md +26 -1
- data/docs/guides/integrations.md +20 -161
- data/docs/guides/mcp.md +288 -0
- data/docs/guides/models_and_providers.md +21 -54
- data/docs/guides/production.md +14 -5
- data/docs/guides/providers.md +275 -0
- data/docs/guides/sandboxing.md +42 -7
- data/docs/guides/skills.md +1 -3
- data/docs/guides/tools.md +2 -0
- data/exe/little_ghost +12 -0
- data/lib/little_ghost/assembly.rb +11 -3
- data/lib/little_ghost/assembly_builder.rb +2 -2
- data/lib/little_ghost/cli.rb +138 -0
- data/lib/little_ghost/console.rb +17 -0
- data/lib/little_ghost/errors.rb +1 -1
- data/lib/little_ghost/generators/application_generator.rb +179 -0
- data/lib/little_ghost/generators/templates/application/Gemfile.tt +3 -0
- data/lib/little_ghost/generators/templates/application/README.md.tt +29 -0
- data/lib/little_ghost/generators/templates/application/app/agents/application_agent.rb.tt +5 -0
- data/lib/little_ghost/generators/templates/application/app/prompts/application/system.erb.tt +1 -0
- data/lib/little_ghost/generators/templates/application/bin/application.tt +10 -0
- data/lib/little_ghost/generators/templates/application/config/little_ghost.rb.tt +5 -0
- data/lib/little_ghost/generators/templates/application/gitignore.tt +2 -0
- data/lib/little_ghost/generators/templates/application/keep.tt +0 -0
- data/lib/little_ghost/mcp/client.rb +295 -606
- data/lib/little_ghost/mcp/toolset.rb +133 -95
- data/lib/little_ghost/mcp.rb +21 -3
- data/lib/little_ghost/model_resolver.rb +2 -0
- data/lib/little_ghost/models/catalog/models_dev_source.rb +4 -1
- data/lib/little_ghost/provider_registry.rb +1 -0
- data/lib/little_ghost/providers/lm_studio/catalog_source.rb +201 -0
- data/lib/little_ghost/providers/lm_studio.rb +52 -0
- data/lib/little_ghost/providers/openai.rb +3 -75
- data/lib/little_ghost/providers/openai_compatible.rb +78 -1
- data/lib/little_ghost/sandbox/environment_policy.rb +29 -4
- data/lib/little_ghost/sandbox/policy.rb +28 -6
- data/lib/little_ghost/sandboxes/unrestricted.rb +7 -3
- data/lib/little_ghost/skills/catalog.rb +13 -3
- data/lib/little_ghost/tool.rb +6 -1
- data/lib/little_ghost/version.rb +1 -1
- data/lib/little_ghost.rb +1 -0
- metadata +37 -7
- 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
|
-
#
|
|
12
|
-
#
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
55
|
-
raise ArgumentError, "max_response_bytes must be positive" unless @max_response_bytes.positive?
|
|
18
|
+
attr_reader :transport
|
|
56
19
|
|
|
57
|
-
|
|
58
|
-
@
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
121
|
-
|
|
127
|
+
class Session # :nodoc:
|
|
128
|
+
attr_reader :client
|
|
122
129
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
142
|
-
return value if SESSION_ID_PATTERN.match?(value)
|
|
165
|
+
private
|
|
143
166
|
|
|
144
|
-
|
|
167
|
+
def invalidate!
|
|
168
|
+
@close_mutex.synchronize { @unavailable = true }
|
|
169
|
+
close
|
|
145
170
|
end
|
|
146
|
-
end
|
|
147
171
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
definitions = list_tool_definitions(context:).map do |raw|
|
|
201
|
+
generated = discover_tools(context:).filter_map do |mcp_tool|
|
|
231
202
|
context&.check!
|
|
232
|
-
|
|
203
|
+
build_tool(mcp_tool, binding:)
|
|
233
204
|
end
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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(
|
|
232
|
+
@result_mapper.call(value, result:, mcp_tool:, arguments:, binding:)
|
|
267
233
|
else
|
|
268
|
-
|
|
234
|
+
value
|
|
269
235
|
end
|
|
270
236
|
context&.check!
|
|
271
|
-
tool_result(mapped,
|
|
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
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
266
|
+
discovered.concat(page.tools)
|
|
267
|
+
cursor = page.next_cursor
|
|
268
|
+
break unless cursor
|
|
304
269
|
|
|
305
|
-
|
|
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
|
|
342
|
-
context
|
|
343
|
-
|
|
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
|
|
349
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
|
374
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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(
|
|
401
|
-
|
|
402
|
-
|
|
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
|
|
405
|
-
description
|
|
406
|
-
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(:
|
|
329
|
+
define_singleton_method(:mcp_tool) { mcp_tool }
|
|
409
330
|
define_method(:call) do |input|
|
|
410
|
-
|
|
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,
|
|
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
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
|
505
|
-
|
|
506
|
-
|
|
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
|
|
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.
|
|
379
|
+
if existing && existing.mcp_tool.name != tool_class.mcp_tool.name
|
|
517
380
|
raise ConfigurationError,
|
|
518
|
-
"MCP tools #{existing.
|
|
381
|
+
"MCP tools #{existing.mcp_tool.name.inspect} and #{tool_class.mcp_tool.name.inspect} map to #{name.inspect}"
|
|
519
382
|
end
|
|
520
|
-
|
|
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
|
|
528
|
-
|
|
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
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
416
|
+
def default_value(result, content:)
|
|
417
|
+
return result["structuredContent"] if result.key?("structuredContent")
|
|
678
418
|
|
|
679
|
-
text =
|
|
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 =
|
|
422
|
+
visible = content.reject { |block| block["type"] == "image" }
|
|
683
423
|
return visible unless visible.empty?
|
|
684
424
|
|
|
685
|
-
|
|
686
|
-
"images" =>
|
|
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
|
-
}
|
|
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
|
-
|
|
700
|
-
|
|
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
|
-
|
|
712
|
-
|
|
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
|
|
731
|
-
|
|
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
|