Class: DatagroutConduit::Client

Inherits:
Object
  • Object
show all
Defined in:
lib/datagrout_conduit/client.rb

Overview

Main Conduit client. Connects to remote MCP / JSONRPC servers over HTTP, sends requests, and parses responses. This is purely a client library —it does NOT run a server or accept connections.

Constant Summary collapse

PROTOCOL_VERSION =
"2025-03-26"
CLIENT_NAME =
"datagrout-conduit-ruby"

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(url:, auth: {}, transport: :mcp, identity: nil, identity_dir: nil, use_intelligent_interface: nil, max_retries: 3, logger: nil, disable_mtls: false) ⇒ Client

Returns a new instance of Client.



33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# File 'lib/datagrout_conduit/client.rb', line 33

def initialize(url:, auth: {}, transport: :mcp, identity: nil, identity_dir: nil,
               use_intelligent_interface: nil, max_retries: 3, logger: nil, disable_mtls: false)
  @url = url
  @auth = auth
  @transport_mode = transport
  @identity = identity
  @identity_dir = identity_dir
  @disable_mtls = disable_mtls
  @max_retries = max_retries
  @initialized = false
  @server_info = nil
  @logger = logger || default_logger
  @is_dg = DatagroutConduit.dg_url?(url)
  @dg_warned = false

  @use_intelligent_interface = if use_intelligent_interface.nil?
                                 @is_dg
                               else
                                 use_intelligent_interface
                               end

  resolve_identity!
  @transport = build_transport
end

Instance Attribute Details

#server_infoObject (readonly)

Returns the value of attribute server_info.



14
15
16
# File 'lib/datagrout_conduit/client.rb', line 14

def server_info
  @server_info
end

#transportObject (readonly)

Returns the value of attribute transport.



14
15
16
# File 'lib/datagrout_conduit/client.rb', line 14

def transport
  @transport
end

#use_intelligent_interfaceObject (readonly)

Returns the value of attribute use_intelligent_interface.



14
15
16
# File 'lib/datagrout_conduit/client.rb', line 14

def use_intelligent_interface
  @use_intelligent_interface
end

Class Method Details

.bootstrap_identity(url:, auth_token:, name: "conduit-client", identity_dir: nil) ⇒ Object

Bootstrap an mTLS identity: discover existing or register a new one.

Checks the auto-discovery chain first. If an existing identity is found and not near expiry it is used as-is. Otherwise, a new keypair is generated, registered with DataGrout using the provided bearer token, saved to the identity directory, and loaded as the active identity.

After the first successful bootstrap the identity is persisted locally and auto-discovered on subsequent runs — no token or API key is needed.



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/datagrout_conduit/client.rb', line 67

def self.bootstrap_identity(url:, auth_token:, name: "conduit-client", identity_dir: nil)
  dir = identity_dir || Registration.default_identity_dir || File.join(Dir.home, ".conduit")

  identity = Identity.try_discover(override_dir: dir)
  if identity && !identity.needs_rotation?
    return new(url: url, identity: identity)
  end

  private_pem, public_pem = Registration.generate_keypair
  reg = Registration.register_identity(
    public_pem,
    auth_token: auth_token,
    name: name
  )
  Registration.save_identity(reg.cert_pem, private_pem, dir, ca_pem: reg.ca_cert_pem)

  ca_path = reg.ca_cert_pem ? File.join(dir, "ca.pem") : nil
  identity = Identity.from_paths(
    File.join(dir, "identity.pem"),
    File.join(dir, "identity_key.pem"),
    ca_path: ca_path
  )

  new(url: url, identity: identity)
end

.bootstrap_identity_oauth(url:, client_id:, client_secret:, name: "conduit-client", identity_dir: nil) ⇒ Object

Bootstrap an mTLS identity using OAuth 2.1 client_credentials.

Same flow as bootstrap_identity but obtains the bearer token via OAuth client_credentials exchange first.



97
98
99
100
101
102
103
104
105
# File 'lib/datagrout_conduit/client.rb', line 97

def self.bootstrap_identity_oauth(url:, client_id:, client_secret:, name: "conduit-client", identity_dir: nil)
  provider = OAuth::TokenProvider.new(
    client_id: client_id,
    client_secret: client_secret,
    token_endpoint: OAuth::TokenProvider.derive_token_endpoint(url)
  )
  token = provider.get_token
  bootstrap_identity(url: url, auth_token: token, name: name, identity_dir: identity_dir)
end

Instance Method Details

#call_tool(name, arguments = {}) ⇒ Object



169
170
171
172
173
174
175
176
# File 'lib/datagrout_conduit/client.rb', line 169

def call_tool(name, arguments = {})
  ensure_initialized!

  params = { "name" => name.to_s, "arguments" => normalize_hash(arguments) }
  response = send_with_retry("tools/call", params)
  result = response.is_a?(Hash) ? (response["result"] || response) : response
  unwrap_content(result)
end

#connectObject



107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'lib/datagrout_conduit/client.rb', line 107

def connect
  @transport.connect

  params = {
    "protocolVersion" => PROTOCOL_VERSION,
    "clientInfo" => { "name" => CLIENT_NAME, "version" => DatagroutConduit::VERSION },
    "capabilities" => { "tools" => {} }
  }

  response = @transport.send_request("initialize", params)

  if response.is_a?(Hash) && response["result"]
    result = response["result"]
    @server_info = result["serverInfo"]
  end

  @transport.send_request("notifications/initialized", nil, id: nil)
  @initialized = true
  self
end

#deliverablesObject



229
230
231
# File 'lib/datagrout_conduit/client.rb', line 229

def deliverables
  @deliverables ||= DeliverablesNamespace.new(self)
end

#dg(tool_short_name, params = {}) ⇒ Object

Call any DataGrout first-party tool by short name. e.g. client.dg(“prism.render”, { payload: data, goal: “summary” })



326
327
328
329
# File 'lib/datagrout_conduit/client.rb', line 326

def dg(tool_short_name, params = {})
  ensure_initialized!
  call_dg_tool("data-grout/#{tool_short_name}", params)
end

#disconnectObject



128
129
130
131
132
# File 'lib/datagrout_conduit/client.rb', line 128

def disconnect
  @transport.disconnect
  @initialized = false
  self
end

#discover(goal: nil, query: nil, limit: 10, min_score: 0.0, integrations: [], servers: []) ⇒ Object

Semantic discovery — find tools by natural language goal or query.



246
247
248
249
250
251
252
253
254
255
256
257
258
259
# File 'lib/datagrout_conduit/client.rb', line 246

def discover(goal: nil, query: nil, limit: 10, min_score: 0.0,
             integrations: [], servers: [])
  warn_if_not_dg("discover")
  ensure_initialized!

  params = { "limit" => limit, "min_score" => min_score }
  params["goal"] = goal if goal
  params["query"] = query if query
  params["integrations"] = integrations unless integrations.empty?
  params["servers"] = servers unless servers.empty?

  result = call_dg_tool("data-grout/discovery.discover", params)
  DiscoverResult.from_hash(result)
end

#ephemeralsObject



233
234
235
# File 'lib/datagrout_conduit/client.rb', line 233

def ephemerals
  @ephemerals ||= EphemeralsNamespace.new(self)
end

#estimate_cost(tool_name, arguments = {}) ⇒ Object

Estimate cost before execution.



332
333
334
335
336
337
# File 'lib/datagrout_conduit/client.rb', line 332

def estimate_cost(tool_name, arguments = {})
  ensure_initialized!

  args = normalize_hash(arguments).merge("estimate_only" => true)
  call_dg_tool(tool_name.to_s, args)
end

#flowObject



237
238
239
# File 'lib/datagrout_conduit/client.rb', line 237

def flow
  @flow ||= FlowNamespace.new(self)
end

#get_prompt(name, arguments = {}) ⇒ Object



202
203
204
205
206
207
208
209
210
211
# File 'lib/datagrout_conduit/client.rb', line 202

def get_prompt(name, arguments = {})
  ensure_initialized!

  params = { "name" => name.to_s }
  params["arguments"] = normalize_hash(arguments) unless arguments.nil? || arguments.empty?

  response = send_with_retry("prompts/get", params)
  result = response.is_a?(Hash) ? (response["result"] || response) : response
  result["messages"] || []
end

#guide(goal: nil, session_id: nil, choice: nil) ⇒ Object

Start or continue a guided workflow.



291
292
293
294
295
296
297
298
299
300
301
302
# File 'lib/datagrout_conduit/client.rb', line 291

def guide(goal: nil, session_id: nil, choice: nil)
  warn_if_not_dg("guide")
  ensure_initialized!

  params = {}
  params["goal"] = goal if goal
  params["session_id"] = session_id if session_id
  params["choice"] = choice if choice

  result = call_dg_tool("data-grout/discovery.guide", params)
  GuidedSession.new(self, GuideState.from_hash(result))
end

#initialized?Boolean

Returns:

  • (Boolean)


134
135
136
# File 'lib/datagrout_conduit/client.rb', line 134

def initialized?
  @initialized
end

#list_promptsObject



194
195
196
197
198
199
200
# File 'lib/datagrout_conduit/client.rb', line 194

def list_prompts
  ensure_initialized!

  response = send_with_retry("prompts/list", {})
  result = response.is_a?(Hash) ? (response["result"] || response) : response
  result["prompts"] || []
end

#list_resourcesObject



178
179
180
181
182
183
184
# File 'lib/datagrout_conduit/client.rb', line 178

def list_resources
  ensure_initialized!

  response = send_with_retry("resources/list", {})
  result = response.is_a?(Hash) ? (response["result"] || response) : response
  result["resources"] || []
end

#list_toolsObject

Standard MCP Methods



142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/datagrout_conduit/client.rb', line 142

def list_tools
  ensure_initialized!

  all_tools = []
  cursor = nil

  loop do
    params = {}
    params["cursor"] = cursor if cursor

    response = send_with_retry("tools/list", params)
    result = response.is_a?(Hash) ? (response["result"] || response) : response

    tools_data = result["tools"] || []
    tools_data.each { |t| all_tools << Tool.from_hash(t) }

    cursor = result["nextCursor"] || result["next_cursor"]
    break unless cursor
  end

  if @use_intelligent_interface
    all_tools.reject! { |t| t.name.include?("@") }
  end

  all_tools
end

#logicObject



221
222
223
# File 'lib/datagrout_conduit/client.rb', line 221

def logic
  @logic ||= LogicNamespace.new(self)
end

#perform(tool_name, arguments = {}, demux: false, demux_mode: nil) ⇒ Object

Execute a tool call through the DataGrout intelligent interface.



262
263
264
265
266
267
268
269
270
271
# File 'lib/datagrout_conduit/client.rb', line 262

def perform(tool_name, arguments = {}, demux: false, demux_mode: nil)
  warn_if_not_dg("perform")
  ensure_initialized!

  params = { "tool" => tool_name.to_s, "args" => normalize_hash(arguments) }
  params["demux"] = demux if demux
  params["demux_mode"] = demux_mode if demux_mode

  call_dg_tool("data-grout/discovery.perform", params)
end

#perform_batch(calls) ⇒ Object

Execute multiple tool calls in a single gateway request.

Each element should be a hash with “tool” and “args” keys. Returns an array of results in the same order as the input calls.

results = client.perform_batch([
  { "tool" => "data-grout/data.count", "args" => { "data" => [1, 2, 3] } },
  { "tool" => "data-grout/data.keys",  "args" => { "data" => { "a" => 1 } } }
])


282
283
284
285
286
287
288
# File 'lib/datagrout_conduit/client.rb', line 282

def perform_batch(calls)
  warn_if_not_dg("perform_batch")
  ensure_initialized!

  result = call_dg_tool("data-grout/discovery.perform", calls)
  result.is_a?(Array) ? result : [result]
end

#plan(goal: nil, query: nil, **opts) ⇒ Object

Semantic discovery plan — return a ranked list of tools for a goal. At least one of ‘goal:` or `query:` must be provided.

Raises:

  • (ArgumentError)


306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
# File 'lib/datagrout_conduit/client.rb', line 306

def plan(goal: nil, query: nil, **opts)
  raise ArgumentError, "plan() requires at least one of goal: or query:" unless goal || query

  params = {}
  params["goal"] = goal if goal
  params["query"] = query if query
  params["server"] = opts[:server] if opts[:server]
  params["k"] = opts[:k] if opts[:k]
  params["policy"] = opts[:policy] if opts[:policy]
  params["have"] = opts[:have] if opts[:have]
  params["return_call_handles"] = opts[:return_call_handles] if opts.key?(:return_call_handles)
  params["expose_virtual_skills"] = opts[:expose_virtual_skills] if opts.key?(:expose_virtual_skills)
  params["model_overrides"] = opts[:model_overrides] if opts[:model_overrides]
  warn_if_not_dg("plan")
  ensure_initialized!
  call_dg_tool("data-grout/discovery.plan", params)
end

#prismObject

Namespace Accessors



217
218
219
# File 'lib/datagrout_conduit/client.rb', line 217

def prism
  @prism ||= PrismNamespace.new(self)
end

#read_resource(uri) ⇒ Object



186
187
188
189
190
191
192
# File 'lib/datagrout_conduit/client.rb', line 186

def read_resource(uri)
  ensure_initialized!

  response = send_with_retry("resources/read", { "uri" => uri.to_s })
  result = response.is_a?(Hash) ? (response["result"] || response) : response
  result["contents"] || []
end

#subscribe(topic) ⇒ Object

Subscribe to a server-push topic over a WebSocket transport. Returns a Transport::Ws::Subscription. Raises RuntimeError when transport is not :websocket.



19
20
21
22
23
# File 'lib/datagrout_conduit/client.rb', line 19

def subscribe(topic)
  raise "subscribe() requires transport: :websocket" unless @transport.is_a?(Transport::Ws)

  @transport.subscribe(topic)
end

#unsubscribe(subscription) ⇒ Object

Cancel a push subscription. Accepts a Subscription object or a subscription ID string.



27
28
29
30
31
# File 'lib/datagrout_conduit/client.rb', line 27

def unsubscribe(subscription)
  raise "unsubscribe() requires transport: :websocket" unless @transport.is_a?(Transport::Ws)

  @transport.unsubscribe(subscription)
end

#wardenObject



225
226
227
# File 'lib/datagrout_conduit/client.rb', line 225

def warden
  @warden ||= WardenNamespace.new(self)
end