ruby_llm_swarm-mcp 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 +7 -0
- data/LICENSE +21 -0
- data/README.md +277 -0
- data/lib/generators/ruby_llm/mcp/install/install_generator.rb +42 -0
- data/lib/generators/ruby_llm/mcp/install/templates/initializer.rb +56 -0
- data/lib/generators/ruby_llm/mcp/install/templates/mcps.yml +29 -0
- data/lib/generators/ruby_llm/mcp/oauth/install_generator.rb +354 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/mcp_token_storage.rb.tt +114 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/user_mcp_oauth_concern.rb.tt +90 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/controllers/mcp_connections_controller.rb.tt +239 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/cleanup_expired_oauth_states_job.rb.tt +27 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/example_job.rb.tt +78 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/lib/mcp_client.rb.tt +68 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_credentials.rb.tt +19 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_states.rb.tt +21 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_credential.rb.tt +54 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_state.rb.tt +30 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/views/index.html.erb +646 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/views/show.html.erb +560 -0
- data/lib/ruby_llm/chat.rb +34 -0
- data/lib/ruby_llm/mcp/adapters/base_adapter.rb +179 -0
- data/lib/ruby_llm/mcp/adapters/mcp_sdk_adapter.rb +292 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/coordinator_stub.rb +33 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/sse.rb +52 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/stdio.rb +52 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/streamable_http.rb +86 -0
- data/lib/ruby_llm/mcp/adapters/ruby_llm_adapter.rb +92 -0
- data/lib/ruby_llm/mcp/attachment.rb +18 -0
- data/lib/ruby_llm/mcp/auth/browser/callback_handler.rb +71 -0
- data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +30 -0
- data/lib/ruby_llm/mcp/auth/browser/http_server.rb +112 -0
- data/lib/ruby_llm/mcp/auth/browser/opener.rb +39 -0
- data/lib/ruby_llm/mcp/auth/browser/pages.rb +607 -0
- data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +280 -0
- data/lib/ruby_llm/mcp/auth/client_registrar.rb +170 -0
- data/lib/ruby_llm/mcp/auth/discoverer.rb +124 -0
- data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +105 -0
- data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +66 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/authorization_code.rb +31 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/base.rb +31 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/client_credentials.rb +31 -0
- data/lib/ruby_llm/mcp/auth/http_response_handler.rb +63 -0
- data/lib/ruby_llm/mcp/auth/memory_storage.rb +90 -0
- data/lib/ruby_llm/mcp/auth/oauth_provider.rb +305 -0
- data/lib/ruby_llm/mcp/auth/security.rb +44 -0
- data/lib/ruby_llm/mcp/auth/session_manager.rb +54 -0
- data/lib/ruby_llm/mcp/auth/token_manager.rb +236 -0
- data/lib/ruby_llm/mcp/auth/transport_oauth_helper.rb +107 -0
- data/lib/ruby_llm/mcp/auth/url_builder.rb +76 -0
- data/lib/ruby_llm/mcp/auth.rb +359 -0
- data/lib/ruby_llm/mcp/client.rb +401 -0
- data/lib/ruby_llm/mcp/completion.rb +16 -0
- data/lib/ruby_llm/mcp/configuration.rb +310 -0
- data/lib/ruby_llm/mcp/content.rb +28 -0
- data/lib/ruby_llm/mcp/elicitation.rb +48 -0
- data/lib/ruby_llm/mcp/error.rb +34 -0
- data/lib/ruby_llm/mcp/errors.rb +91 -0
- data/lib/ruby_llm/mcp/logging.rb +16 -0
- data/lib/ruby_llm/mcp/native/cancellable_operation.rb +57 -0
- data/lib/ruby_llm/mcp/native/client.rb +387 -0
- data/lib/ruby_llm/mcp/native/json_rpc.rb +170 -0
- data/lib/ruby_llm/mcp/native/messages/helpers.rb +39 -0
- data/lib/ruby_llm/mcp/native/messages/notifications.rb +42 -0
- data/lib/ruby_llm/mcp/native/messages/requests.rb +206 -0
- data/lib/ruby_llm/mcp/native/messages/responses.rb +106 -0
- data/lib/ruby_llm/mcp/native/messages.rb +36 -0
- data/lib/ruby_llm/mcp/native/notification.rb +16 -0
- data/lib/ruby_llm/mcp/native/protocol.rb +36 -0
- data/lib/ruby_llm/mcp/native/response_handler.rb +110 -0
- data/lib/ruby_llm/mcp/native/transport.rb +88 -0
- data/lib/ruby_llm/mcp/native/transports/sse.rb +607 -0
- data/lib/ruby_llm/mcp/native/transports/stdio.rb +356 -0
- data/lib/ruby_llm/mcp/native/transports/streamable_http.rb +926 -0
- data/lib/ruby_llm/mcp/native/transports/support/http_client.rb +28 -0
- data/lib/ruby_llm/mcp/native/transports/support/rate_limit.rb +49 -0
- data/lib/ruby_llm/mcp/native/transports/support/timeout.rb +36 -0
- data/lib/ruby_llm/mcp/native.rb +12 -0
- data/lib/ruby_llm/mcp/notification_handler.rb +100 -0
- data/lib/ruby_llm/mcp/progress.rb +35 -0
- data/lib/ruby_llm/mcp/prompt.rb +132 -0
- data/lib/ruby_llm/mcp/railtie.rb +14 -0
- data/lib/ruby_llm/mcp/resource.rb +112 -0
- data/lib/ruby_llm/mcp/resource_template.rb +85 -0
- data/lib/ruby_llm/mcp/result.rb +108 -0
- data/lib/ruby_llm/mcp/roots.rb +45 -0
- data/lib/ruby_llm/mcp/sample.rb +152 -0
- data/lib/ruby_llm/mcp/server_capabilities.rb +49 -0
- data/lib/ruby_llm/mcp/tool.rb +228 -0
- data/lib/ruby_llm/mcp/version.rb +7 -0
- data/lib/ruby_llm/mcp.rb +125 -0
- data/lib/tasks/release.rake +23 -0
- metadata +184 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module MCP
|
|
5
|
+
module Native
|
|
6
|
+
module Transports
|
|
7
|
+
class Stdio
|
|
8
|
+
include Support::Timeout
|
|
9
|
+
|
|
10
|
+
attr_reader :command, :stdin, :stdout, :stderr, :id, :coordinator
|
|
11
|
+
|
|
12
|
+
# Default environment that merges with user-provided env
|
|
13
|
+
# This ensures PATH and other critical env vars are preserved
|
|
14
|
+
DEFAULT_ENV = ENV.to_h.freeze
|
|
15
|
+
|
|
16
|
+
def initialize(command:, coordinator:, request_timeout:, args: [], env: {})
|
|
17
|
+
@request_timeout = request_timeout
|
|
18
|
+
@command = command
|
|
19
|
+
@coordinator = coordinator
|
|
20
|
+
@args = args
|
|
21
|
+
# Merge provided env with default environment (user env takes precedence)
|
|
22
|
+
@env = DEFAULT_ENV.merge(env || {})
|
|
23
|
+
@client_id = SecureRandom.uuid
|
|
24
|
+
|
|
25
|
+
@id_counter = 0
|
|
26
|
+
@id_mutex = Mutex.new
|
|
27
|
+
@pending_requests = {}
|
|
28
|
+
@pending_mutex = Mutex.new
|
|
29
|
+
@state_mutex = Mutex.new
|
|
30
|
+
@running = false
|
|
31
|
+
@reader_thread = nil
|
|
32
|
+
@stderr_thread = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def request(body, wait_for_response: true)
|
|
36
|
+
request_id = prepare_request_id(body, wait_for_response)
|
|
37
|
+
response_queue = register_pending_request(request_id, wait_for_response)
|
|
38
|
+
|
|
39
|
+
send_request(body, request_id)
|
|
40
|
+
|
|
41
|
+
return unless wait_for_response
|
|
42
|
+
|
|
43
|
+
wait_for_request_response(request_id, response_queue)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def alive?
|
|
47
|
+
running?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def running?
|
|
51
|
+
@state_mutex.synchronize { @running }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def start
|
|
55
|
+
@state_mutex.synchronize do
|
|
56
|
+
return if @running
|
|
57
|
+
|
|
58
|
+
@running = true
|
|
59
|
+
end
|
|
60
|
+
start_process
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def close
|
|
64
|
+
@state_mutex.synchronize do
|
|
65
|
+
return unless @running
|
|
66
|
+
|
|
67
|
+
@running = false
|
|
68
|
+
end
|
|
69
|
+
shutdown_process
|
|
70
|
+
fail_pending_requests!(RubyLLM::MCP::Errors::TransportError.new(message: "Transport closed"))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def set_protocol_version(version)
|
|
74
|
+
@protocol_version = version
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def prepare_request_id(body, wait_for_response)
|
|
80
|
+
request_id = body["id"] || body[:id]
|
|
81
|
+
|
|
82
|
+
if wait_for_response && request_id.nil?
|
|
83
|
+
raise ArgumentError, "Request ID must be provided in message body when wait_for_response is true"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
request_id
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def register_pending_request(request_id, wait_for_response)
|
|
90
|
+
return nil unless wait_for_response
|
|
91
|
+
|
|
92
|
+
response_queue = Queue.new
|
|
93
|
+
@pending_mutex.synchronize do
|
|
94
|
+
@pending_requests[request_id.to_s] = response_queue
|
|
95
|
+
end
|
|
96
|
+
response_queue
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def send_request(body, request_id)
|
|
100
|
+
body = JSON.generate(body)
|
|
101
|
+
RubyLLM::MCP.logger.debug "Sending Request: #{body}"
|
|
102
|
+
@stdin.puts(body)
|
|
103
|
+
@stdin.flush
|
|
104
|
+
rescue IOError, Errno::EPIPE => e
|
|
105
|
+
@pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) } if request_id
|
|
106
|
+
raise RubyLLM::MCP::Errors::TransportError.new(message: e.message, error: e)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def wait_for_request_response(request_id, response_queue)
|
|
110
|
+
with_timeout(@request_timeout / 1000, request_id: request_id) do
|
|
111
|
+
response_queue.pop
|
|
112
|
+
end
|
|
113
|
+
rescue RubyLLM::MCP::Errors::TimeoutError => e
|
|
114
|
+
@pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
|
|
115
|
+
log_message = "Stdio request timeout (ID: #{request_id}) after #{@request_timeout / 1000} seconds"
|
|
116
|
+
RubyLLM::MCP.logger.error(log_message)
|
|
117
|
+
raise e
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def start_process
|
|
121
|
+
shutdown_process if @stdin || @stdout || @stderr || @wait_thread
|
|
122
|
+
|
|
123
|
+
# Always pass env - it now includes defaults merged with user overrides
|
|
124
|
+
@stdin, @stdout, @stderr, @wait_thread = Open3.popen3(@env, @command, *@args)
|
|
125
|
+
|
|
126
|
+
start_reader_thread
|
|
127
|
+
start_stderr_thread
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def shutdown_process
|
|
131
|
+
close_stdin
|
|
132
|
+
terminate_child_process
|
|
133
|
+
close_output_streams
|
|
134
|
+
join_reader_threads
|
|
135
|
+
clear_process_handles
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def close_stdin
|
|
139
|
+
@stdin&.close
|
|
140
|
+
rescue IOError, Errno::EBADF
|
|
141
|
+
# Already closed
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def terminate_child_process
|
|
145
|
+
return unless @wait_thread
|
|
146
|
+
|
|
147
|
+
@wait_thread.join(1) if @wait_thread.alive? # 1s grace period
|
|
148
|
+
send_signal_to_process("TERM", 2) if @wait_thread.alive?
|
|
149
|
+
send_signal_to_process("KILL", 0) if @wait_thread.alive?
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def send_signal_to_process(signal, wait_time)
|
|
153
|
+
Process.kill(signal, @wait_thread.pid)
|
|
154
|
+
@wait_thread.join(wait_time) if wait_time.positive?
|
|
155
|
+
rescue StandardError => e
|
|
156
|
+
RubyLLM::MCP.logger.debug "Error sending #{signal}: #{e.message}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def close_output_streams
|
|
160
|
+
[@stdout, @stderr].each do |stream|
|
|
161
|
+
stream&.close
|
|
162
|
+
rescue IOError, Errno::EBADF
|
|
163
|
+
# Already closed
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def join_reader_threads
|
|
168
|
+
[@reader_thread, @stderr_thread].each do |thread|
|
|
169
|
+
next unless thread&.alive?
|
|
170
|
+
next if Thread.current == thread # Avoid self-join deadlock
|
|
171
|
+
|
|
172
|
+
thread.join(1)
|
|
173
|
+
rescue StandardError => e
|
|
174
|
+
RubyLLM::MCP.logger.debug "Error joining thread: #{e.message}"
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def clear_process_handles
|
|
179
|
+
@stdin = @stdout = @stderr = nil
|
|
180
|
+
@wait_thread = @reader_thread = @stderr_thread = nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def fail_pending_requests!(error)
|
|
184
|
+
@pending_mutex.synchronize do
|
|
185
|
+
@pending_requests.each_value do |queue|
|
|
186
|
+
queue.push(error)
|
|
187
|
+
end
|
|
188
|
+
@pending_requests.clear
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def safe_close_with_error(error)
|
|
193
|
+
fail_pending_requests!(error)
|
|
194
|
+
close
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def start_reader_thread
|
|
198
|
+
@reader_thread = Thread.new do
|
|
199
|
+
read_stdout_loop
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def read_stdout_loop
|
|
204
|
+
while running?
|
|
205
|
+
begin
|
|
206
|
+
handle_stdout_read
|
|
207
|
+
rescue IOError, Errno::EPIPE => e
|
|
208
|
+
handle_stream_error(e, "Reader")
|
|
209
|
+
break unless running?
|
|
210
|
+
rescue StandardError => e
|
|
211
|
+
RubyLLM::MCP.logger.error "Error in reader thread: #{e.message}, #{e.backtrace.join("\n")}"
|
|
212
|
+
sleep 1
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def handle_stdout_read
|
|
218
|
+
if @stdout.closed? || @wait_thread.nil? || !@wait_thread.alive?
|
|
219
|
+
# Process is dead - if we're still running, this is an error
|
|
220
|
+
if running?
|
|
221
|
+
error = RubyLLM::MCP::Errors::TransportError.new(
|
|
222
|
+
message: "Process terminated unexpectedly"
|
|
223
|
+
)
|
|
224
|
+
safe_close_with_error(error)
|
|
225
|
+
end
|
|
226
|
+
return
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
line = @stdout.gets
|
|
230
|
+
return unless line && !line.strip.empty?
|
|
231
|
+
|
|
232
|
+
process_response(line.strip)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def handle_stream_error(error, stream_name)
|
|
236
|
+
if running?
|
|
237
|
+
RubyLLM::MCP.logger.error "#{stream_name} error: #{error.message}. Closing transport."
|
|
238
|
+
safe_close_with_error(error)
|
|
239
|
+
else
|
|
240
|
+
RubyLLM::MCP.logger.debug "#{stream_name} thread exiting during shutdown"
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def start_stderr_thread
|
|
245
|
+
@stderr_thread = Thread.new do
|
|
246
|
+
read_stderr_loop
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def read_stderr_loop
|
|
251
|
+
while running?
|
|
252
|
+
begin
|
|
253
|
+
handle_stderr_read
|
|
254
|
+
rescue IOError, Errno::EPIPE => e
|
|
255
|
+
handle_stream_error(e, "Stderr reader")
|
|
256
|
+
break unless running?
|
|
257
|
+
rescue StandardError => e
|
|
258
|
+
RubyLLM::MCP.logger.error "Error in stderr thread: #{e.message}"
|
|
259
|
+
sleep 1
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def handle_stderr_read
|
|
265
|
+
if @stderr.closed? || @wait_thread.nil? || !@wait_thread.alive?
|
|
266
|
+
return
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
line = @stderr.gets
|
|
270
|
+
return unless line && !line.strip.empty?
|
|
271
|
+
|
|
272
|
+
RubyLLM::MCP.logger.info(line.strip)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def process_response(line)
|
|
276
|
+
response = parse_and_validate_envelope(line)
|
|
277
|
+
return unless response
|
|
278
|
+
|
|
279
|
+
request_id = response["id"]&.to_s
|
|
280
|
+
result = RubyLLM::MCP::Result.new(response)
|
|
281
|
+
RubyLLM::MCP.logger.debug "Result Received: #{result.inspect}"
|
|
282
|
+
|
|
283
|
+
result = @coordinator.process_result(result)
|
|
284
|
+
return if result.nil?
|
|
285
|
+
|
|
286
|
+
@pending_mutex.synchronize do
|
|
287
|
+
if result.matching_id?(request_id) && @pending_requests.key?(request_id)
|
|
288
|
+
response_queue = @pending_requests.delete(request_id)
|
|
289
|
+
response_queue&.push(result)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def parse_and_validate_envelope(line)
|
|
295
|
+
response = JSON.parse(line)
|
|
296
|
+
|
|
297
|
+
# Validate JSON-RPC envelope
|
|
298
|
+
validator = Native::JsonRpc::EnvelopeValidator.new(response)
|
|
299
|
+
unless validator.valid?
|
|
300
|
+
RubyLLM::MCP.logger.error("Invalid JSON-RPC envelope: #{validator.error_message}\nRaw: #{line}")
|
|
301
|
+
|
|
302
|
+
# If this is a request with an id, send an error response
|
|
303
|
+
if response.is_a?(Hash) && response["id"]
|
|
304
|
+
send_invalid_request_error(response["id"], validator.error_message)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
return nil
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
response
|
|
311
|
+
rescue JSON::ParserError => e
|
|
312
|
+
RubyLLM::MCP.logger.error("JSON parse error: #{e.message}\nRaw response: #{line}")
|
|
313
|
+
|
|
314
|
+
# JSON-RPC 2.0 §5.1: Parse error should return error with id: null
|
|
315
|
+
send_parse_error(e.message)
|
|
316
|
+
nil
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def send_invalid_request_error(id, detail)
|
|
320
|
+
error_body = Native::Messages::Responses.error(
|
|
321
|
+
id: id,
|
|
322
|
+
message: "Invalid Request",
|
|
323
|
+
code: Native::JsonRpc::ErrorCodes::INVALID_REQUEST,
|
|
324
|
+
data: { detail: detail }
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
begin
|
|
328
|
+
body_json = JSON.generate(error_body)
|
|
329
|
+
@stdin.puts(body_json)
|
|
330
|
+
@stdin.flush
|
|
331
|
+
rescue IOError, Errno::EPIPE => e
|
|
332
|
+
RubyLLM::MCP.logger.error("Failed to send invalid request error: #{e.message}")
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def send_parse_error(detail)
|
|
337
|
+
error_body = Native::Messages::Responses.error(
|
|
338
|
+
id: nil,
|
|
339
|
+
message: "Parse error",
|
|
340
|
+
code: Native::JsonRpc::ErrorCodes::PARSE_ERROR,
|
|
341
|
+
data: { detail: detail }
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
begin
|
|
345
|
+
body_json = JSON.generate(error_body)
|
|
346
|
+
@stdin.puts(body_json)
|
|
347
|
+
@stdin.flush
|
|
348
|
+
rescue IOError, Errno::EPIPE => e
|
|
349
|
+
RubyLLM::MCP.logger.error("Failed to send parse error: #{e.message}")
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|