datagrout-conduit 0.3.0 → 0.5.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 +45 -3
- data/lib/datagrout_conduit/client.rb +60 -5
- data/lib/datagrout_conduit/identity.rb +6 -7
- data/lib/datagrout_conduit/onramp.rb +155 -0
- data/lib/datagrout_conduit/transport/ws.rb +478 -0
- data/lib/datagrout_conduit/version.rb +1 -1
- data/lib/datagrout_conduit.rb +2 -0
- metadata +18 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d3a08c2cb30deefe712f0083b473838a1ec6b578c1b7decbcc53ac14aca4cffb
|
|
4
|
+
data.tar.gz: ea88b13e4a5998164c4dfaa1b71d448e5d498b694b8de312449a8c82d4daf555
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 70c40acb181dcc382395c454e769e26fede6798a32dc2dab7797ffaf66b1936dd0e89cf767f2f9f16a5e668ad1ceff9dfd3d2a22d03db34bcb146ab1eef78ba6
|
|
7
|
+
data.tar.gz: 3c2cf29896ae08b63876e60e8807ccb7ea85118fdb5d422aaf9b94e4ef3aa76345786b42bb0406f1c8df1b938d3755cf78a84fd3bf9728f5ecc4fd54a6c7b737
|
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.
|
|
12
|
+
gem "datagrout-conduit", "~> 0.5.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.5.0
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
## Quick Start
|
|
@@ -168,7 +168,49 @@ client = DatagroutConduit::Client.new(
|
|
|
168
168
|
)
|
|
169
169
|
```
|
|
170
170
|
|
|
171
|
-
|
|
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,14 +13,33 @@ 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
|
-
use_intelligent_interface: nil, max_retries: 3, logger: nil,
|
|
34
|
+
use_intelligent_interface: nil, max_retries: 3, logger: nil,
|
|
35
|
+
identity_auto: false, disable_mtls: false)
|
|
18
36
|
@url = url
|
|
19
37
|
@auth = auth
|
|
20
38
|
@transport_mode = transport
|
|
21
39
|
@identity = identity
|
|
22
40
|
@identity_dir = identity_dir
|
|
23
|
-
@
|
|
41
|
+
@identity_auto = identity_auto
|
|
42
|
+
@disable_mtls = disable_mtls # deprecated, kept for backward compat
|
|
24
43
|
@max_retries = max_retries
|
|
25
44
|
@initialized = false
|
|
26
45
|
@server_info = nil
|
|
@@ -87,6 +106,38 @@ module DatagroutConduit
|
|
|
87
106
|
bootstrap_identity(url: url, auth_token: token, name: name, identity_dir: identity_dir)
|
|
88
107
|
end
|
|
89
108
|
|
|
109
|
+
# Bootstrap by performing the autonomous DG onramp flow.
|
|
110
|
+
#
|
|
111
|
+
# The all-in-one flow: onramp (no prior credentials required) →
|
|
112
|
+
# OAuth token exchange → mTLS identity registration and persistence.
|
|
113
|
+
#
|
|
114
|
+
# On subsequent runs the saved mTLS identity is auto-discovered and
|
|
115
|
+
# no credentials are needed.
|
|
116
|
+
#
|
|
117
|
+
# @param opts [DatagroutConduit::Onramp::OnrampOptions]
|
|
118
|
+
# @param url [String, nil] MCP server URL; required when +opts.mcp_url+ is absent
|
|
119
|
+
# @param name [String] human-readable identity label
|
|
120
|
+
# @param identity_dir [String, nil] custom identity storage directory
|
|
121
|
+
# @return [Client] unconnected client; call +#connect+ before use
|
|
122
|
+
def self.bootstrap_onramp(opts:, url: nil, name: "conduit-client", identity_dir: nil)
|
|
123
|
+
dir = identity_dir || Registration.default_identity_dir || File.join(Dir.home, ".conduit")
|
|
124
|
+
|
|
125
|
+
# Fast path: existing valid identity.
|
|
126
|
+
identity = Identity.try_discover(override_dir: dir)
|
|
127
|
+
if identity && !identity.needs_rotation?
|
|
128
|
+
raise ArgumentError, "'url' must be provided when an existing identity is reused" if url.nil?
|
|
129
|
+
return new(url: url, identity: identity)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Slow path: full onramp flow.
|
|
133
|
+
creds, token = Onramp.register_and_exchange(opts)
|
|
134
|
+
|
|
135
|
+
mcp_url = creds.mcp_url || url
|
|
136
|
+
raise ArgumentError, "'url' must be provided when mcp_url is absent from onramp response" if mcp_url.nil?
|
|
137
|
+
|
|
138
|
+
bootstrap_identity(url: mcp_url, auth_token: token, name: name, identity_dir: identity_dir)
|
|
139
|
+
end
|
|
140
|
+
|
|
90
141
|
def connect
|
|
91
142
|
@transport.connect
|
|
92
143
|
|
|
@@ -389,15 +440,19 @@ module DatagroutConduit
|
|
|
389
440
|
# transport, transparently rewrite the path to the DG JSONRPC endpoint.
|
|
390
441
|
rpc_url = @url.end_with?("/mcp") ? @url.sub(%r{/mcp$}, "/rpc") : @url
|
|
391
442
|
Transport::JsonRpc.new(url: rpc_url, auth: @auth, identity: @identity)
|
|
443
|
+
when :websocket, "websocket"
|
|
444
|
+
ws_url = @url
|
|
445
|
+
.sub(/\Ahttps:\/\//, "wss://")
|
|
446
|
+
.sub(/\Ahttp:\/\//, "ws://")
|
|
447
|
+
Transport::Ws.new(url: ws_url, auth: @auth, identity: @identity)
|
|
392
448
|
else
|
|
393
|
-
raise ConfigError, "Unknown transport: #{@transport_mode}. Use :mcp or :
|
|
449
|
+
raise ConfigError, "Unknown transport: #{@transport_mode}. Use :mcp, :jsonrpc, or :websocket."
|
|
394
450
|
end
|
|
395
451
|
end
|
|
396
452
|
|
|
397
453
|
def resolve_identity!
|
|
398
454
|
return if @identity
|
|
399
|
-
return
|
|
400
|
-
return unless @is_dg
|
|
455
|
+
return unless @identity_auto
|
|
401
456
|
|
|
402
457
|
@identity = Identity.try_discover(override_dir: @identity_dir)
|
|
403
458
|
end
|
|
@@ -67,31 +67,30 @@ module DatagroutConduit
|
|
|
67
67
|
# Walk the auto-discovery chain and return the first identity found,
|
|
68
68
|
# or nil if nothing is available.
|
|
69
69
|
def self.try_discover(override_dir: nil)
|
|
70
|
-
#
|
|
70
|
+
# When an explicit directory is given, scope search to that dir only.
|
|
71
71
|
if override_dir
|
|
72
|
-
|
|
73
|
-
return id if id
|
|
72
|
+
return try_load_from_dir(override_dir)
|
|
74
73
|
end
|
|
75
74
|
|
|
76
|
-
#
|
|
75
|
+
# 1. Environment variables (individual cert/key PEMs)
|
|
77
76
|
id = from_env
|
|
78
77
|
return id if id
|
|
79
78
|
|
|
80
|
-
#
|
|
79
|
+
# 2. CONDUIT_IDENTITY_DIR env var
|
|
81
80
|
identity_dir = ENV["CONDUIT_IDENTITY_DIR"]
|
|
82
81
|
if identity_dir && !identity_dir.empty?
|
|
83
82
|
id = try_load_from_dir(identity_dir)
|
|
84
83
|
return id if id
|
|
85
84
|
end
|
|
86
85
|
|
|
87
|
-
#
|
|
86
|
+
# 3. ~/.conduit/
|
|
88
87
|
home = ENV["HOME"] || ENV["USERPROFILE"]
|
|
89
88
|
if home
|
|
90
89
|
id = try_load_from_dir(File.join(home, ".conduit"))
|
|
91
90
|
return id if id
|
|
92
91
|
end
|
|
93
92
|
|
|
94
|
-
#
|
|
93
|
+
# 4. .conduit/ relative to cwd
|
|
95
94
|
id = try_load_from_dir(File.join(Dir.pwd, ".conduit"))
|
|
96
95
|
return id if id
|
|
97
96
|
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module DatagroutConduit
|
|
7
|
+
# Autonomous agent self-registration (onramp) for DataGrout.
|
|
8
|
+
#
|
|
9
|
+
# The onramp flow lets a machine intelligence register itself with DG
|
|
10
|
+
# without a human in the loop, using only plain HTTP JSON — no MCP client
|
|
11
|
+
# required.
|
|
12
|
+
#
|
|
13
|
+
# == Flow
|
|
14
|
+
#
|
|
15
|
+
# 1. POST to +/onramp+ with agent identity metadata (no auth).
|
|
16
|
+
# 2. DG returns a short-lived +session_token+ (5 minutes).
|
|
17
|
+
# 3. POST to +/onramp/complete+ with +Authorization: Bearer <session_token>+.
|
|
18
|
+
# 4. DG issues provisional +client_id+ + +client_secret+ (restricted scopes).
|
|
19
|
+
#
|
|
20
|
+
# == Example
|
|
21
|
+
#
|
|
22
|
+
# opts = DatagroutConduit::Onramp::OnrampOptions.new(
|
|
23
|
+
# gateway: "https://app.datagrout.ai",
|
|
24
|
+
# agent_name: "my-research-agent",
|
|
25
|
+
# agent_type: "claude-sonnet-4-6"
|
|
26
|
+
# )
|
|
27
|
+
# client = DatagroutConduit::Client.bootstrap_onramp(opts: opts, url: nil)
|
|
28
|
+
module Onramp
|
|
29
|
+
# Options for the autonomous agent onramp flow.
|
|
30
|
+
OnrampOptions = Struct.new(
|
|
31
|
+
:gateway,
|
|
32
|
+
:agent_name,
|
|
33
|
+
:agent_type,
|
|
34
|
+
:intended_use,
|
|
35
|
+
:access_code,
|
|
36
|
+
keyword_init: true
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Provisional credentials returned by the DG onramp complete endpoint.
|
|
40
|
+
#
|
|
41
|
+
# Store +client_id+ and +client_secret+ securely — the secret is shown
|
|
42
|
+
# exactly once and cannot be recovered after this point.
|
|
43
|
+
OnrampCredentials = Struct.new(
|
|
44
|
+
:client_id,
|
|
45
|
+
:client_secret,
|
|
46
|
+
:token_url,
|
|
47
|
+
:scopes,
|
|
48
|
+
:expires_in,
|
|
49
|
+
:rpc_url,
|
|
50
|
+
:mcp_url,
|
|
51
|
+
keyword_init: true
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
class OnrampError < StandardError; end
|
|
55
|
+
|
|
56
|
+
# Perform the onramp handshake and return provisional OAuth credentials.
|
|
57
|
+
#
|
|
58
|
+
# Low-level entry point. Most callers should use
|
|
59
|
+
# {DatagroutConduit::Client.bootstrap_onramp} instead.
|
|
60
|
+
#
|
|
61
|
+
# @param opts [OnrampOptions]
|
|
62
|
+
# @return [OnrampCredentials]
|
|
63
|
+
def self.register_only(opts)
|
|
64
|
+
register(opts)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Perform the full onramp handshake and OAuth token exchange.
|
|
68
|
+
#
|
|
69
|
+
# @param opts [OnrampOptions]
|
|
70
|
+
# @return [Array(OnrampCredentials, String)] credentials and access token
|
|
71
|
+
def self.register_and_exchange(opts)
|
|
72
|
+
creds = register(opts)
|
|
73
|
+
token = exchange_token(creds)
|
|
74
|
+
[creds, token]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @api private
|
|
78
|
+
def self.register(opts)
|
|
79
|
+
base = opts.gateway.chomp("/")
|
|
80
|
+
|
|
81
|
+
body = { agent_name: opts.agent_name }
|
|
82
|
+
body[:agent_type] = opts.agent_type if opts.agent_type
|
|
83
|
+
body[:intended_use] = opts.intended_use if opts.intended_use
|
|
84
|
+
body[:access_code] = opts.access_code if opts.access_code
|
|
85
|
+
|
|
86
|
+
conn = build_conn
|
|
87
|
+
|
|
88
|
+
init_resp = conn.post("#{base}/onramp") do |req|
|
|
89
|
+
req.headers["Content-Type"] = "application/json"
|
|
90
|
+
req.body = JSON.generate(body)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
raise OnrampError, "onramp init rejected (HTTP #{init_resp.status}): #{init_resp.body}" \
|
|
94
|
+
unless init_resp.success?
|
|
95
|
+
|
|
96
|
+
init_data = parse_body(init_resp.body)
|
|
97
|
+
session_token = init_data["session_token"]
|
|
98
|
+
|
|
99
|
+
complete_resp = conn.post("#{base}/onramp/complete") do |req|
|
|
100
|
+
req.headers["Authorization"] = "Bearer #{session_token}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
raise OnrampError, "onramp complete rejected (HTTP #{complete_resp.status}): #{complete_resp.body}" \
|
|
104
|
+
unless complete_resp.success?
|
|
105
|
+
|
|
106
|
+
data = parse_body(complete_resp.body)
|
|
107
|
+
|
|
108
|
+
OnrampCredentials.new(
|
|
109
|
+
client_id: data["client_id"],
|
|
110
|
+
client_secret: data["client_secret"],
|
|
111
|
+
token_url: data["token_url"],
|
|
112
|
+
scopes: data["scopes"] || [],
|
|
113
|
+
expires_in: data["expires_in"] || 0,
|
|
114
|
+
rpc_url: data["rpc_url"],
|
|
115
|
+
mcp_url: data["mcp_url"]
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
private_class_method :register
|
|
119
|
+
|
|
120
|
+
# @api private
|
|
121
|
+
def self.exchange_token(creds)
|
|
122
|
+
conn = Faraday.new do |f|
|
|
123
|
+
f.request :url_encoded
|
|
124
|
+
f.response :json, content_type: /\bjson$/
|
|
125
|
+
f.adapter Faraday.default_adapter
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
resp = conn.post(creds.token_url, {
|
|
129
|
+
grant_type: "client_credentials",
|
|
130
|
+
client_id: creds.client_id,
|
|
131
|
+
client_secret: creds.client_secret
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
raise OnrampError, "token exchange failed (HTTP #{resp.status}): #{resp.body}" \
|
|
135
|
+
unless resp.success?
|
|
136
|
+
|
|
137
|
+
data = parse_body(resp.body)
|
|
138
|
+
data["access_token"]
|
|
139
|
+
end
|
|
140
|
+
private_class_method :exchange_token
|
|
141
|
+
|
|
142
|
+
def self.build_conn
|
|
143
|
+
Faraday.new do |f|
|
|
144
|
+
f.response :json, content_type: /\bjson$/
|
|
145
|
+
f.adapter Faraday.default_adapter
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
private_class_method :build_conn
|
|
149
|
+
|
|
150
|
+
def self.parse_body(body)
|
|
151
|
+
body.is_a?(String) ? JSON.parse(body) : body
|
|
152
|
+
end
|
|
153
|
+
private_class_method :parse_body
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -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
|
data/lib/datagrout_conduit.rb
CHANGED
|
@@ -8,9 +8,11 @@ require_relative "datagrout_conduit/types"
|
|
|
8
8
|
require_relative "datagrout_conduit/identity"
|
|
9
9
|
require_relative "datagrout_conduit/oauth"
|
|
10
10
|
require_relative "datagrout_conduit/registration"
|
|
11
|
+
require_relative "datagrout_conduit/onramp"
|
|
11
12
|
require_relative "datagrout_conduit/transport/base"
|
|
12
13
|
require_relative "datagrout_conduit/transport/mcp"
|
|
13
14
|
require_relative "datagrout_conduit/transport/jsonrpc"
|
|
15
|
+
require_relative "datagrout_conduit/transport/ws"
|
|
14
16
|
require_relative "datagrout_conduit/namespaces/prism"
|
|
15
17
|
require_relative "datagrout_conduit/namespaces/logic"
|
|
16
18
|
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.
|
|
4
|
+
version: 0.5.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-
|
|
11
|
+
date: 2026-05-10 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
|
|
@@ -115,10 +129,12 @@ files:
|
|
|
115
129
|
- lib/datagrout_conduit/namespaces/prism.rb
|
|
116
130
|
- lib/datagrout_conduit/namespaces/warden.rb
|
|
117
131
|
- lib/datagrout_conduit/oauth.rb
|
|
132
|
+
- lib/datagrout_conduit/onramp.rb
|
|
118
133
|
- lib/datagrout_conduit/registration.rb
|
|
119
134
|
- lib/datagrout_conduit/transport/base.rb
|
|
120
135
|
- lib/datagrout_conduit/transport/jsonrpc.rb
|
|
121
136
|
- lib/datagrout_conduit/transport/mcp.rb
|
|
137
|
+
- lib/datagrout_conduit/transport/ws.rb
|
|
122
138
|
- lib/datagrout_conduit/types.rb
|
|
123
139
|
- lib/datagrout_conduit/version.rb
|
|
124
140
|
homepage: https://github.com/DataGrout/conduit-sdk
|