datagrout-conduit 0.2.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 28e196cb29ede549875a2c3ecb87eeff2b2c6479d6a24a6a095d00b4891ff11c
4
- data.tar.gz: 45eeee43e6fe5eaee59fd2527c8d48b9317cece38d503637b7225abf11d6b67d
3
+ metadata.gz: 27d8af58b9252394051bb3c764f30bc19b5062664a3bac0295e5bd7aa4e8df94
4
+ data.tar.gz: 4f6c526fe03897d6666d2b0c76c61651dc9bc71e1eaa269f55f35e321a34671f
5
5
  SHA512:
6
- metadata.gz: 4e763c86181e3d7ef415e3bf07cb7544d76bb064d725c78fbf006a6adc8f3ef95922cee7a375bdf5aded71c26679cf06d30548c7e7cb56fd672c2b7f212b40d0
7
- data.tar.gz: 44eb007fc146df62969f0428b1d0ced58c8cba78167ca67df5744a25314686023b99a73eb6a4a4b1b2a11377921969a3cb7b8b2e88e4e11a635790ed7b29b405
6
+ metadata.gz: bba7f383d85b62c05fff950f527047c830b82842536a31151988d87414c5da597a1b67160f507753a8c43c5cd41f0e9cf463a1bdd93ab0bb898165da7a1afc18
7
+ data.tar.gz: a8c3fec760966850b04ec6c5e9b0c4af46f967b87a9627130bb6bb18cf84157bd1fa0da90486cc64ff01e97c03d815e153e7e1bccd77579f6b93a5775b75df6f
data/README.md CHANGED
@@ -9,13 +9,13 @@ Connect to remote MCP and JSONRPC servers, invoke tools, discover capabilities w
9
9
  Add to your Gemfile:
10
10
 
11
11
  ```ruby
12
- gem "datagrout-conduit", "~> 0.1"
12
+ gem "datagrout-conduit", "~> 0.4.0"
13
13
  ```
14
14
 
15
15
  Or install directly:
16
16
 
17
17
  ```sh
18
- gem install datagrout-conduit
18
+ gem install datagrout-conduit -v 0.4.0
19
19
  ```
20
20
 
21
21
  ## Quick Start
@@ -168,7 +168,49 @@ client = DatagroutConduit::Client.new(
168
168
  )
169
169
  ```
170
170
 
171
- Both transports send JSON-RPC 2.0 requests via HTTP POST. MCP uses the MCP Streamable HTTP framing. Both configure Faraday SSL with mTLS client certificates when an identity is present.
171
+ ### WebSocket (`datagrout-jsonrpc.v1`)
172
+
173
+ ```ruby
174
+ client = DatagroutConduit::Client.new(
175
+ url: "wss://gateway.datagrout.ai/servers/{uuid}/ws",
176
+ auth: { bearer: "your-token" },
177
+ transport: :websocket
178
+ )
179
+ client.connect
180
+ ```
181
+
182
+ Bidirectional push over a single `wss://` connection using `websocket-driver ~> 0.7` (the library underlying Rails ActionCable — no EventMachine dependency). A background `Thread` runs the read loop; shared state is protected by a `Mutex`.
183
+
184
+ #### Push subscriptions
185
+
186
+ ```ruby
187
+ # Subscribe — returns a Subscription with recv + each (Enumerable)
188
+ sub = client.subscribe("agents.my-agent-id.events")
189
+
190
+ # Block on the next event
191
+ event = sub.recv(timeout: 30)
192
+ puts "#{event.event}: #{event.data.inspect}"
193
+
194
+ # Or iterate until the subscription is closed
195
+ sub.each do |event|
196
+ puts "#{event.event}: #{event.data.inspect}"
197
+ end
198
+
199
+ # Unsubscribe when done
200
+ client.unsubscribe(sub)
201
+ ```
202
+
203
+ Supported topics:
204
+
205
+ | Topic | Fires when |
206
+ |-------|-----------|
207
+ | `agents.<agent_id>.events` | Agent lifecycle events (plan started, IC completed, grounding failed, …) |
208
+ | `tools.<tool_name>.results` | A specific tool call completes |
209
+ | `tasks.<task_id>.*` | Long-running background task transitions |
210
+ | `flows.<flow_id>.*` | `flow.into` progress and completion |
211
+ | `governor.<server_uuid>` | Governor percept events (file change, schedule, webhook) |
212
+
213
+ **Reconnection**: after a disconnect, `send_request` and `subscribe` raise `DatagroutConduit::NotInitializedError`. Call `client.connect` again and re-subscribe — subscriptions do not survive reconnects in v0.4.
172
214
 
173
215
  ## Standard MCP Methods
174
216
 
@@ -13,6 +13,23 @@ module DatagroutConduit
13
13
 
14
14
  attr_reader :transport, :server_info, :use_intelligent_interface
15
15
 
16
+ # Subscribe to a server-push topic over a WebSocket transport.
17
+ # Returns a {DatagroutConduit::Transport::Ws::Subscription}.
18
+ # Raises RuntimeError when transport is not :websocket.
19
+ def subscribe(topic)
20
+ raise "subscribe() requires transport: :websocket" unless @transport.is_a?(Transport::Ws)
21
+
22
+ @transport.subscribe(topic)
23
+ end
24
+
25
+ # Cancel a push subscription.
26
+ # Accepts a Subscription object or a subscription ID string.
27
+ def unsubscribe(subscription)
28
+ raise "unsubscribe() requires transport: :websocket" unless @transport.is_a?(Transport::Ws)
29
+
30
+ @transport.unsubscribe(subscription)
31
+ end
32
+
16
33
  def initialize(url:, auth: {}, transport: :mcp, identity: nil, identity_dir: nil,
17
34
  use_intelligent_interface: nil, max_retries: 3, logger: nil, disable_mtls: false)
18
35
  @url = url
@@ -389,8 +406,13 @@ module DatagroutConduit
389
406
  # transport, transparently rewrite the path to the DG JSONRPC endpoint.
390
407
  rpc_url = @url.end_with?("/mcp") ? @url.sub(%r{/mcp$}, "/rpc") : @url
391
408
  Transport::JsonRpc.new(url: rpc_url, auth: @auth, identity: @identity)
409
+ when :websocket, "websocket"
410
+ ws_url = @url
411
+ .sub(/\Ahttps:\/\//, "wss://")
412
+ .sub(/\Ahttp:\/\//, "ws://")
413
+ Transport::Ws.new(url: ws_url, auth: @auth, identity: @identity)
392
414
  else
393
- raise ConfigError, "Unknown transport: #{@transport_mode}. Use :mcp or :jsonrpc."
415
+ raise ConfigError, "Unknown transport: #{@transport_mode}. Use :mcp, :jsonrpc, or :websocket."
394
416
  end
395
417
  end
396
418
 
@@ -0,0 +1,478 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "websocket/driver"
4
+ require "socket"
5
+ require "openssl"
6
+ require "uri"
7
+ require "thread"
8
+ require "json"
9
+ require "securerandom"
10
+ require "base64"
11
+ require "timeout"
12
+
13
+ module DatagroutConduit
14
+ module Transport
15
+ # WebSocket transport for datagrout-jsonrpc.v1.
16
+ #
17
+ # Manages a single wss:// connection with concurrent JSON-RPC request
18
+ # multiplexing and server-push subscriptions. Uses a background thread
19
+ # for frame reading; callers block on Thread::Queue for responses.
20
+ #
21
+ # Usage:
22
+ # ws = DatagroutConduit::Transport::Ws.new(
23
+ # url: "wss://gateway.datagrout.ai/servers/<uuid>/ws",
24
+ # auth: { bearer: "token" }
25
+ # )
26
+ # ws.connect
27
+ # result = ws.send_request("tools/list")
28
+ # sub = ws.subscribe("agents.my-agent.events")
29
+ # event = sub.recv
30
+ # ws.unsubscribe(sub)
31
+ # ws.disconnect
32
+ class Ws
33
+ SUBPROTOCOL = "datagrout-jsonrpc.v1"
34
+
35
+ # ── Subscription ─────────────────────────────────────────────────────────
36
+
37
+ # Per-subscription event stream delivered via a thread-safe Queue.
38
+ # Call recv to block until the next event, or iterate with each.
39
+ class Subscription
40
+ attr_reader :sub_id, :topic
41
+
42
+ def initialize(sub_id, topic)
43
+ @sub_id = sub_id
44
+ @topic = topic
45
+ @queue = ::Thread::Queue.new
46
+ @closed = false
47
+ end
48
+
49
+ # Block until the next event arrives.
50
+ # Returns nil and raises StopIteration on close when iterating via each.
51
+ # @param timeout [Numeric, nil] optional timeout in seconds; returns nil on expiry
52
+ def recv(timeout: nil)
53
+ event =
54
+ if timeout
55
+ Timeout.timeout(timeout) { @queue.pop }
56
+ else
57
+ @queue.pop
58
+ end
59
+
60
+ raise StopIteration if event.nil?
61
+
62
+ event
63
+ rescue Timeout::Error
64
+ nil
65
+ end
66
+
67
+ # Iterate over events until the subscription is closed.
68
+ def each(&block)
69
+ loop do
70
+ event = @queue.pop
71
+ break if event.nil?
72
+ block.call(event)
73
+ end
74
+ end
75
+
76
+ include Enumerable
77
+
78
+ def closed?
79
+ @closed
80
+ end
81
+
82
+ # @api private
83
+ def _enqueue(event)
84
+ @queue.push(event) unless @closed
85
+ end
86
+
87
+ # @api private
88
+ def _close
89
+ return if @closed
90
+
91
+ @closed = true
92
+ @queue.push(nil)
93
+ end
94
+ end
95
+
96
+ # Value object for a push notification delivered to a subscription.
97
+ SubscriptionEvent = Struct.new(:subscription, :event, :data, keyword_init: true)
98
+
99
+ # ── Construction ─────────────────────────────────────────────────────────
100
+
101
+ def initialize(url:, auth: {}, identity: nil)
102
+ @url = url
103
+ @auth = normalize_auth(auth)
104
+ @identity = identity
105
+ @mutex = Mutex.new
106
+ @write_mutex = Mutex.new
107
+
108
+ @pending = {} # id => RequestFuture
109
+ @pending_subscribe = {} # id => { topic:, future: }
110
+ @subscriptions = {} # sub_id => [Subscription, ...]
111
+ @next_id = 0
112
+
113
+ @io = nil
114
+ @driver = nil
115
+ @read_thread = nil
116
+ @connected = false
117
+ end
118
+
119
+ # ── Public API ────────────────────────────────────────────────────────────
120
+
121
+ # Establish the WebSocket connection.
122
+ # Blocks until the server-side handshake completes (up to 10 s).
123
+ def connect
124
+ uri = URI.parse(@url)
125
+ @io = open_socket(uri)
126
+
127
+ adapter = SocketAdapter.new(@url, @io)
128
+ @driver = WebSocket::Driver.client(adapter, protocols: [SUBPROTOCOL])
129
+
130
+ build_upgrade_headers.each { |k, v| @driver.set_header(k, v) }
131
+
132
+ handshake_q = ::Thread::Queue.new
133
+
134
+ @driver.on(:open) { handshake_q.push(nil) unless @connected }
135
+ @driver.on(:message) { |e| handle_message(e.data) }
136
+ @driver.on(:close) { handle_disconnect }
137
+ @driver.on(:error) { |e| handshake_q.push(e.message) unless @connected }
138
+
139
+ @driver.start
140
+ @read_thread = Thread.new { read_loop }
141
+ @read_thread.abort_on_exception = false
142
+ @read_thread.name = "conduit-ws-reader"
143
+
144
+ err = Timeout.timeout(10) { handshake_q.pop }
145
+ raise ConnectionError, "WebSocket handshake failed: #{err}" if err
146
+
147
+ @connected = true
148
+ self
149
+ rescue Timeout::Error
150
+ cleanup_socket
151
+ raise ConnectionError, "WebSocket connection timed out"
152
+ rescue ConnectionError
153
+ raise
154
+ rescue => e
155
+ cleanup_socket
156
+ raise ConnectionError, "WebSocket connect error: #{e.message}"
157
+ end
158
+
159
+ # Close the connection and fail all pending requests.
160
+ def disconnect
161
+ @mutex.synchronize { @connected = false }
162
+ fail_all_pending(:disconnected)
163
+ cleanup_socket
164
+ self
165
+ end
166
+
167
+ def connected?
168
+ @connected
169
+ end
170
+
171
+ # Send a JSON-RPC request and block until the response arrives.
172
+ # Pass id: nil to fire a notification (no response expected).
173
+ # Returns the result value, or raises McpError on RPC-level error.
174
+ def send_request(method, params = nil, id: :auto)
175
+ ensure_connected!
176
+
177
+ # id: nil means fire-and-forget notification (no id field, no response wait)
178
+ if id.nil?
179
+ frame = { "jsonrpc" => "2.0", "method" => method }
180
+ frame["params"] = params if params
181
+ write_frame(frame)
182
+ return { "result" => {} }
183
+ end
184
+
185
+ req_id = mint_id
186
+ future = RequestFuture.new
187
+ @mutex.synchronize { @pending[req_id] = future }
188
+
189
+ write_frame(build_request(req_id, method, params))
190
+
191
+ result, value = future.wait
192
+ if result == :ok
193
+ { "result" => value }
194
+ else
195
+ raise McpError.new(code: -1, message: value.to_s, data: nil)
196
+ end
197
+ end
198
+
199
+ # Subscribe to a dotted-namespace topic.
200
+ # Returns a Subscription that delivers events via recv / each.
201
+ def subscribe(topic)
202
+ ensure_connected!
203
+
204
+ req_id = mint_id
205
+ future = RequestFuture.new
206
+ @mutex.synchronize { @pending_subscribe[req_id] = { topic: topic, future: future } }
207
+
208
+ write_frame(build_request(req_id, "subscribe", { "topic" => topic }))
209
+
210
+ result, value = future.wait
211
+ if result == :ok
212
+ sub_id = value.is_a?(Hash) ? (value["subscription"] || req_id) : req_id
213
+ sub = Subscription.new(sub_id, topic)
214
+ @mutex.synchronize do
215
+ @subscriptions[sub_id] ||= []
216
+ @subscriptions[sub_id] << sub
217
+ end
218
+ sub
219
+ else
220
+ raise McpError.new(code: -1, message: value.to_s, data: nil)
221
+ end
222
+ end
223
+
224
+ # Cancel a push subscription locally and notify the server.
225
+ # Accepts a Subscription object or a subscription ID string.
226
+ def unsubscribe(subscription)
227
+ sub_id = subscription.is_a?(Subscription) ? subscription.sub_id : subscription.to_s
228
+
229
+ subs = @mutex.synchronize { @subscriptions.delete(sub_id) || [] }
230
+ subs.each(&:_close)
231
+
232
+ if @connected
233
+ req_id = mint_id
234
+ write_frame(build_request(req_id, "unsubscribe", { "subscription" => sub_id }))
235
+ end
236
+
237
+ :ok
238
+ end
239
+
240
+ private
241
+
242
+ # ── Socket adapter for websocket-driver ──────────────────────────────────
243
+
244
+ class SocketAdapter
245
+ attr_reader :url
246
+
247
+ def initialize(url, io)
248
+ @url = url
249
+ @io = io
250
+ end
251
+
252
+ def write(data)
253
+ @io.write(data)
254
+ data.bytesize
255
+ end
256
+ end
257
+
258
+ # ── Request future ────────────────────────────────────────────────────────
259
+
260
+ class RequestFuture
261
+ def initialize
262
+ @queue = ::Thread::Queue.new
263
+ end
264
+
265
+ def wait(timeout: 30)
266
+ Timeout.timeout(timeout) { @queue.pop }
267
+ rescue Timeout::Error
268
+ [:error, "Request timed out after #{timeout}s"]
269
+ end
270
+
271
+ def resolve(value)
272
+ @queue.push([:ok, value])
273
+ end
274
+
275
+ def reject(reason)
276
+ @queue.push([:error, reason])
277
+ end
278
+ end
279
+
280
+ # ── Socket creation ───────────────────────────────────────────────────────
281
+
282
+ def open_socket(uri)
283
+ host = uri.host
284
+ port = uri.port || (uri.scheme == "wss" ? 443 : 80)
285
+
286
+ tcp = TCPSocket.new(host, port)
287
+ tcp.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
288
+
289
+ if uri.scheme == "wss"
290
+ ctx = build_ssl_context
291
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
292
+ ssl.hostname = host
293
+ ssl.sync_close = true
294
+ ssl.connect
295
+ ssl
296
+ else
297
+ tcp
298
+ end
299
+ end
300
+
301
+ def build_ssl_context
302
+ ctx = OpenSSL::SSL::SSLContext.new
303
+ ctx.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
304
+
305
+ if @identity
306
+ ctx.cert = @identity.openssl_cert
307
+ ctx.key = @identity.openssl_key
308
+ if @identity.ca_pem
309
+ store = OpenSSL::X509::Store.new
310
+ store.add_cert(@identity.openssl_ca)
311
+ ctx.cert_store = store
312
+ end
313
+ end
314
+
315
+ ctx
316
+ end
317
+
318
+ # ── Upgrade headers ───────────────────────────────────────────────────────
319
+
320
+ def build_upgrade_headers
321
+ headers = {}
322
+
323
+ case @auth[:type]
324
+ when :bearer
325
+ headers["Authorization"] = "Bearer #{@auth[:token]}"
326
+ when :api_key
327
+ headers["X-API-Key"] = @auth[:key]
328
+ when :basic
329
+ encoded = Base64.strict_encode64("#{@auth[:username]}:#{@auth[:password]}")
330
+ headers["Authorization"] = "Basic #{encoded}"
331
+ when :oauth
332
+ token = @auth[:provider].get_token
333
+ headers["Authorization"] = "Bearer #{token}"
334
+ end
335
+
336
+ headers
337
+ end
338
+
339
+ # ── Read loop ─────────────────────────────────────────────────────────────
340
+
341
+ def read_loop
342
+ loop do
343
+ data = @io.readpartial(4096)
344
+ @driver.parse(data)
345
+ rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE
346
+ handle_disconnect
347
+ break
348
+ rescue StandardError
349
+ handle_disconnect
350
+ break
351
+ end
352
+ end
353
+
354
+ # ── Message routing ───────────────────────────────────────────────────────
355
+
356
+ def handle_message(raw)
357
+ msg = JSON.parse(raw)
358
+
359
+ str_id = msg["id"]&.to_s
360
+
361
+ if str_id && (entry = @mutex.synchronize { @pending_subscribe.delete(str_id) })
362
+ future = entry[:future]
363
+ if msg["error"]
364
+ future.reject(msg.dig("error", "message") || "Subscribe failed")
365
+ else
366
+ future.resolve(msg["result"] || {})
367
+ end
368
+
369
+ elsif str_id && (future = @mutex.synchronize { @pending.delete(str_id) })
370
+ if msg["error"]
371
+ future.reject(msg.dig("error", "message") || "RPC error")
372
+ else
373
+ future.resolve(msg["result"])
374
+ end
375
+
376
+ elsif msg["method"] == "notification"
377
+ route_notification(msg["params"] || {})
378
+ end
379
+ rescue JSON::ParserError
380
+ # Silently discard malformed frames
381
+ end
382
+
383
+ def route_notification(params)
384
+ sub_id = params["subscription"]
385
+ return unless sub_id.is_a?(String)
386
+
387
+ subs = @mutex.synchronize { @subscriptions[sub_id] }
388
+ return unless subs
389
+
390
+ event = SubscriptionEvent.new(
391
+ subscription: sub_id,
392
+ event: params["event"] || "",
393
+ data: params["data"]
394
+ )
395
+
396
+ subs.each { |sub| sub._enqueue(event) }
397
+ end
398
+
399
+ def handle_disconnect
400
+ was_connected = @mutex.synchronize do
401
+ old = @connected
402
+ @connected = false
403
+ old
404
+ end
405
+
406
+ fail_all_pending(:disconnected) if was_connected
407
+ end
408
+
409
+ def fail_all_pending(reason)
410
+ pending, pending_sub, subs = @mutex.synchronize do
411
+ p = @pending.dup
412
+ ps = @pending_subscribe.dup
413
+ s = @subscriptions.dup
414
+ @pending.clear
415
+ @pending_subscribe.clear
416
+ @subscriptions.clear
417
+ [p, ps, s]
418
+ end
419
+
420
+ pending.each_value { |f| f.reject(reason) }
421
+ pending_sub.each_value { |entry| entry[:future].reject(reason) }
422
+ subs.each_value { |list| list.each(&:_close) }
423
+ end
424
+
425
+ def cleanup_socket
426
+ @write_mutex.synchronize do
427
+ @driver = nil
428
+ end
429
+ @read_thread&.kill
430
+ @read_thread = nil
431
+ @io&.close rescue nil
432
+ @io = nil
433
+ end
434
+
435
+ # ── Helpers ───────────────────────────────────────────────────────────────
436
+
437
+ def ensure_connected!
438
+ raise NotInitializedError, "WebSocket not connected. Call connect() first." unless @connected
439
+ end
440
+
441
+ def mint_id
442
+ @mutex.synchronize do
443
+ @next_id += 1
444
+ "ws-#{@next_id}"
445
+ end
446
+ end
447
+
448
+ def build_request(id, method, params)
449
+ body = { "jsonrpc" => "2.0", "id" => id, "method" => method }
450
+ body["params"] = params if params
451
+ body
452
+ end
453
+
454
+ def write_frame(data)
455
+ json = JSON.generate(data)
456
+ @write_mutex.synchronize { @driver&.text(json) }
457
+ end
458
+
459
+ def normalize_auth(auth)
460
+ return { type: :none } if auth.nil? || auth.empty?
461
+
462
+ auth = auth.transform_keys(&:to_sym) if auth.is_a?(Hash)
463
+
464
+ if auth[:bearer]
465
+ { type: :bearer, token: auth[:bearer] }
466
+ elsif auth[:api_key]
467
+ { type: :api_key, key: auth[:api_key] }
468
+ elsif auth[:basic]
469
+ { type: :basic, username: auth[:basic][:username], password: auth[:basic][:password] }
470
+ elsif auth[:oauth] || auth[:provider]
471
+ { type: :oauth, provider: auth[:oauth] || auth[:provider] }
472
+ else
473
+ { type: :none }
474
+ end
475
+ end
476
+ end
477
+ end
478
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DatagroutConduit
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -11,6 +11,7 @@ require_relative "datagrout_conduit/registration"
11
11
  require_relative "datagrout_conduit/transport/base"
12
12
  require_relative "datagrout_conduit/transport/mcp"
13
13
  require_relative "datagrout_conduit/transport/jsonrpc"
14
+ require_relative "datagrout_conduit/transport/ws"
14
15
  require_relative "datagrout_conduit/namespaces/prism"
15
16
  require_relative "datagrout_conduit/namespaces/logic"
16
17
  require_relative "datagrout_conduit/namespaces/warden"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: datagrout-conduit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - DataGrout
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-19 00:00:00.000000000 Z
11
+ date: 2026-05-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -52,6 +52,20 @@ dependencies:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
54
  version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: websocket-driver
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.7'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.7'
55
69
  - !ruby/object:Gem::Dependency
56
70
  name: minitest
57
71
  requirement: !ruby/object:Gem::Requirement
@@ -119,6 +133,7 @@ files:
119
133
  - lib/datagrout_conduit/transport/base.rb
120
134
  - lib/datagrout_conduit/transport/jsonrpc.rb
121
135
  - lib/datagrout_conduit/transport/mcp.rb
136
+ - lib/datagrout_conduit/transport/ws.rb
122
137
  - lib/datagrout_conduit/types.rb
123
138
  - lib/datagrout_conduit/version.rb
124
139
  homepage: https://github.com/DataGrout/conduit-sdk