datagrout-conduit 0.1.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/README.md +303 -0
- data/lib/datagrout_conduit/client.rb +601 -0
- data/lib/datagrout_conduit/errors.rb +56 -0
- data/lib/datagrout_conduit/identity.rb +175 -0
- data/lib/datagrout_conduit/oauth.rb +85 -0
- data/lib/datagrout_conduit/registration.rb +220 -0
- data/lib/datagrout_conduit/transport/base.rb +158 -0
- data/lib/datagrout_conduit/transport/jsonrpc.rb +36 -0
- data/lib/datagrout_conduit/transport/mcp.rb +107 -0
- data/lib/datagrout_conduit/types.rb +142 -0
- data/lib/datagrout_conduit/version.rb +5 -0
- data/lib/datagrout_conduit.rb +53 -0
- metadata +145 -0
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "logger"
|
|
5
|
+
|
|
6
|
+
module DatagroutConduit
|
|
7
|
+
# Main Conduit client. Connects to remote MCP / JSONRPC servers over HTTP,
|
|
8
|
+
# sends requests, and parses responses. This is purely a client library —
|
|
9
|
+
# it does NOT run a server or accept connections.
|
|
10
|
+
class Client
|
|
11
|
+
PROTOCOL_VERSION = "2025-03-26"
|
|
12
|
+
CLIENT_NAME = "datagrout-conduit-ruby"
|
|
13
|
+
|
|
14
|
+
attr_reader :transport, :server_info, :use_intelligent_interface
|
|
15
|
+
|
|
16
|
+
def initialize(url:, auth: {}, transport: :mcp, identity: nil, identity_dir: nil,
|
|
17
|
+
use_intelligent_interface: nil, max_retries: 3, logger: nil, disable_mtls: false)
|
|
18
|
+
@url = url
|
|
19
|
+
@auth = auth
|
|
20
|
+
@transport_mode = transport
|
|
21
|
+
@identity = identity
|
|
22
|
+
@identity_dir = identity_dir
|
|
23
|
+
@disable_mtls = disable_mtls
|
|
24
|
+
@max_retries = max_retries
|
|
25
|
+
@initialized = false
|
|
26
|
+
@server_info = nil
|
|
27
|
+
@logger = logger || default_logger
|
|
28
|
+
@is_dg = DatagroutConduit.dg_url?(url)
|
|
29
|
+
@dg_warned = false
|
|
30
|
+
|
|
31
|
+
@use_intelligent_interface = if use_intelligent_interface.nil?
|
|
32
|
+
@is_dg
|
|
33
|
+
else
|
|
34
|
+
use_intelligent_interface
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
resolve_identity!
|
|
38
|
+
@transport = build_transport
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Bootstrap an mTLS identity: discover existing or register a new one.
|
|
42
|
+
#
|
|
43
|
+
# Checks the auto-discovery chain first. If an existing identity is found
|
|
44
|
+
# and not near expiry it is used as-is. Otherwise, a new keypair is
|
|
45
|
+
# generated, registered with DataGrout using the provided bearer token,
|
|
46
|
+
# saved to the identity directory, and loaded as the active identity.
|
|
47
|
+
#
|
|
48
|
+
# After the first successful bootstrap the identity is persisted locally
|
|
49
|
+
# and auto-discovered on subsequent runs — no token or API key is needed.
|
|
50
|
+
def self.bootstrap_identity(url:, auth_token:, name: "conduit-client", identity_dir: nil)
|
|
51
|
+
dir = identity_dir || Registration.default_identity_dir || File.join(Dir.home, ".conduit")
|
|
52
|
+
|
|
53
|
+
identity = Identity.try_discover(override_dir: dir)
|
|
54
|
+
if identity && !identity.needs_rotation?
|
|
55
|
+
return new(url: url, identity: identity)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private_pem, public_pem = Registration.generate_keypair
|
|
59
|
+
reg = Registration.register_identity(
|
|
60
|
+
public_pem,
|
|
61
|
+
auth_token: auth_token,
|
|
62
|
+
name: name
|
|
63
|
+
)
|
|
64
|
+
Registration.save_identity(reg.cert_pem, private_pem, dir, ca_pem: reg.ca_cert_pem)
|
|
65
|
+
|
|
66
|
+
ca_path = reg.ca_cert_pem ? File.join(dir, "ca.pem") : nil
|
|
67
|
+
identity = Identity.from_paths(
|
|
68
|
+
File.join(dir, "identity.pem"),
|
|
69
|
+
File.join(dir, "identity_key.pem"),
|
|
70
|
+
ca_path: ca_path
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
new(url: url, identity: identity)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Bootstrap an mTLS identity using OAuth 2.1 +client_credentials+.
|
|
77
|
+
#
|
|
78
|
+
# Same flow as {.bootstrap_identity} but obtains the bearer token via
|
|
79
|
+
# OAuth client_credentials exchange first.
|
|
80
|
+
def self.bootstrap_identity_oauth(url:, client_id:, client_secret:, name: "conduit-client", identity_dir: nil)
|
|
81
|
+
provider = OAuth::TokenProvider.new(
|
|
82
|
+
client_id: client_id,
|
|
83
|
+
client_secret: client_secret,
|
|
84
|
+
token_endpoint: OAuth::TokenProvider.derive_token_endpoint(url)
|
|
85
|
+
)
|
|
86
|
+
token = provider.get_token
|
|
87
|
+
bootstrap_identity(url: url, auth_token: token, name: name, identity_dir: identity_dir)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def connect
|
|
91
|
+
@transport.connect
|
|
92
|
+
|
|
93
|
+
params = {
|
|
94
|
+
"protocolVersion" => PROTOCOL_VERSION,
|
|
95
|
+
"clientInfo" => { "name" => CLIENT_NAME, "version" => DatagroutConduit::VERSION },
|
|
96
|
+
"capabilities" => { "tools" => {} }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
response = @transport.send_request("initialize", params)
|
|
100
|
+
|
|
101
|
+
if response.is_a?(Hash) && response["result"]
|
|
102
|
+
result = response["result"]
|
|
103
|
+
@server_info = result["serverInfo"]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
@transport.send_request("notifications/initialized", nil, id: nil)
|
|
107
|
+
@initialized = true
|
|
108
|
+
self
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def disconnect
|
|
112
|
+
@transport.disconnect
|
|
113
|
+
@initialized = false
|
|
114
|
+
self
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def initialized?
|
|
118
|
+
@initialized
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# ================================================================
|
|
122
|
+
# Standard MCP Methods
|
|
123
|
+
# ================================================================
|
|
124
|
+
|
|
125
|
+
def list_tools
|
|
126
|
+
ensure_initialized!
|
|
127
|
+
|
|
128
|
+
all_tools = []
|
|
129
|
+
cursor = nil
|
|
130
|
+
|
|
131
|
+
loop do
|
|
132
|
+
params = {}
|
|
133
|
+
params["cursor"] = cursor if cursor
|
|
134
|
+
|
|
135
|
+
response = send_with_retry("tools/list", params)
|
|
136
|
+
result = response.is_a?(Hash) ? (response["result"] || response) : response
|
|
137
|
+
|
|
138
|
+
tools_data = result["tools"] || []
|
|
139
|
+
tools_data.each { |t| all_tools << Tool.from_hash(t) }
|
|
140
|
+
|
|
141
|
+
cursor = result["nextCursor"] || result["next_cursor"]
|
|
142
|
+
break unless cursor
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
if @use_intelligent_interface
|
|
146
|
+
all_tools.reject! { |t| t.name.include?("@") }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
all_tools
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def call_tool(name, arguments = {})
|
|
153
|
+
ensure_initialized!
|
|
154
|
+
|
|
155
|
+
params = { "name" => name.to_s, "arguments" => normalize_hash(arguments) }
|
|
156
|
+
response = send_with_retry("tools/call", params)
|
|
157
|
+
result = response.is_a?(Hash) ? (response["result"] || response) : response
|
|
158
|
+
unwrap_content(result)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def list_resources
|
|
162
|
+
ensure_initialized!
|
|
163
|
+
|
|
164
|
+
response = send_with_retry("resources/list", {})
|
|
165
|
+
result = response.is_a?(Hash) ? (response["result"] || response) : response
|
|
166
|
+
result["resources"] || []
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def read_resource(uri)
|
|
170
|
+
ensure_initialized!
|
|
171
|
+
|
|
172
|
+
response = send_with_retry("resources/read", { "uri" => uri.to_s })
|
|
173
|
+
result = response.is_a?(Hash) ? (response["result"] || response) : response
|
|
174
|
+
result["contents"] || []
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def list_prompts
|
|
178
|
+
ensure_initialized!
|
|
179
|
+
|
|
180
|
+
response = send_with_retry("prompts/list", {})
|
|
181
|
+
result = response.is_a?(Hash) ? (response["result"] || response) : response
|
|
182
|
+
result["prompts"] || []
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def get_prompt(name, arguments = {})
|
|
186
|
+
ensure_initialized!
|
|
187
|
+
|
|
188
|
+
params = { "name" => name.to_s }
|
|
189
|
+
params["arguments"] = normalize_hash(arguments) unless arguments.nil? || arguments.empty?
|
|
190
|
+
|
|
191
|
+
response = send_with_retry("prompts/get", params)
|
|
192
|
+
result = response.is_a?(Hash) ? (response["result"] || response) : response
|
|
193
|
+
result["messages"] || []
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# ================================================================
|
|
197
|
+
# DataGrout Extensions
|
|
198
|
+
# ================================================================
|
|
199
|
+
|
|
200
|
+
# Semantic discovery — find tools by natural language goal or query.
|
|
201
|
+
def discover(goal: nil, query: nil, limit: 10, min_score: 0.0,
|
|
202
|
+
integrations: [], servers: [])
|
|
203
|
+
warn_if_not_dg("discover")
|
|
204
|
+
ensure_initialized!
|
|
205
|
+
|
|
206
|
+
params = { "limit" => limit, "min_score" => min_score }
|
|
207
|
+
params["goal"] = goal if goal
|
|
208
|
+
params["query"] = query if query
|
|
209
|
+
params["integrations"] = integrations unless integrations.empty?
|
|
210
|
+
params["servers"] = servers unless servers.empty?
|
|
211
|
+
|
|
212
|
+
result = call_dg_tool("data-grout/discovery.discover", params)
|
|
213
|
+
DiscoverResult.from_hash(result)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Execute a tool call through the DataGrout intelligent interface.
|
|
217
|
+
def perform(tool_name, arguments = {}, demux: false, demux_mode: nil)
|
|
218
|
+
warn_if_not_dg("perform")
|
|
219
|
+
ensure_initialized!
|
|
220
|
+
|
|
221
|
+
params = { "tool" => tool_name.to_s, "args" => normalize_hash(arguments) }
|
|
222
|
+
params["demux"] = demux if demux
|
|
223
|
+
params["demux_mode"] = demux_mode if demux_mode
|
|
224
|
+
|
|
225
|
+
call_dg_tool("data-grout/discovery.perform", params)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Start or continue a guided workflow.
|
|
229
|
+
def guide(goal: nil, session_id: nil, choice: nil)
|
|
230
|
+
warn_if_not_dg("guide")
|
|
231
|
+
ensure_initialized!
|
|
232
|
+
|
|
233
|
+
params = {}
|
|
234
|
+
params["goal"] = goal if goal
|
|
235
|
+
params["session_id"] = session_id if session_id
|
|
236
|
+
params["choice"] = choice if choice
|
|
237
|
+
|
|
238
|
+
result = call_dg_tool("data-grout/discovery.guide", params)
|
|
239
|
+
GuidedSession.new(self, GuideState.from_hash(result))
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Execute a multi-step workflow plan.
|
|
243
|
+
def flow_into(plan, validate_ctc: true, save_as_skill: false, input_data: nil)
|
|
244
|
+
warn_if_not_dg("flow_into")
|
|
245
|
+
ensure_initialized!
|
|
246
|
+
|
|
247
|
+
params = {
|
|
248
|
+
"plan" => plan,
|
|
249
|
+
"validate_ctc" => validate_ctc,
|
|
250
|
+
"save_as_skill" => save_as_skill
|
|
251
|
+
}
|
|
252
|
+
params["input_data"] = input_data if input_data
|
|
253
|
+
|
|
254
|
+
call_dg_tool("data-grout/flow.into", params)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Semantic type transformation via Prism.
|
|
258
|
+
def prism_focus(data:, source_type:, target_type:, source_annotations: nil, target_annotations: nil, context: nil)
|
|
259
|
+
params = { "data" => data, "source_type" => source_type, "target_type" => target_type }
|
|
260
|
+
params["source_annotations"] = source_annotations if source_annotations
|
|
261
|
+
params["target_annotations"] = target_annotations if target_annotations
|
|
262
|
+
params["context"] = context if context
|
|
263
|
+
warn_if_not_dg("prism_focus")
|
|
264
|
+
ensure_initialized!
|
|
265
|
+
call_dg_tool("data-grout/prism.focus", params)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Semantic discovery plan — return a ranked list of tools for a goal.
|
|
269
|
+
# At least one of `goal:` or `query:` must be provided.
|
|
270
|
+
def plan(goal: nil, query: nil, **opts)
|
|
271
|
+
raise ArgumentError, "plan() requires at least one of goal: or query:" unless goal || query
|
|
272
|
+
|
|
273
|
+
params = {}
|
|
274
|
+
params["goal"] = goal if goal
|
|
275
|
+
params["query"] = query if query
|
|
276
|
+
params["server"] = opts[:server] if opts[:server]
|
|
277
|
+
params["k"] = opts[:k] if opts[:k]
|
|
278
|
+
params["policy"] = opts[:policy] if opts[:policy]
|
|
279
|
+
params["have"] = opts[:have] if opts[:have]
|
|
280
|
+
params["return_call_handles"] = opts[:return_call_handles] if opts.key?(:return_call_handles)
|
|
281
|
+
params["expose_virtual_skills"] = opts[:expose_virtual_skills] if opts.key?(:expose_virtual_skills)
|
|
282
|
+
params["model_overrides"] = opts[:model_overrides] if opts[:model_overrides]
|
|
283
|
+
warn_if_not_dg("plan")
|
|
284
|
+
ensure_initialized!
|
|
285
|
+
call_dg_tool("data-grout/discovery.plan", params)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Transform / reshape a payload via Prism.
|
|
289
|
+
def refract(goal:, payload:, **opts)
|
|
290
|
+
params = { "goal" => goal, "payload" => payload }
|
|
291
|
+
params["verbose"] = opts[:verbose] if opts.key?(:verbose)
|
|
292
|
+
params["chart"] = opts[:chart] if opts.key?(:chart)
|
|
293
|
+
warn_if_not_dg("refract")
|
|
294
|
+
ensure_initialized!
|
|
295
|
+
call_dg_tool("data-grout/prism.refract", params)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Generate a chart/visual from a payload via Prism.
|
|
299
|
+
def chart(goal:, payload:, **opts)
|
|
300
|
+
params = { "goal" => goal, "payload" => payload }
|
|
301
|
+
params["format"] = opts[:format] if opts[:format]
|
|
302
|
+
params["chart_type"] = opts[:chart_type] if opts[:chart_type]
|
|
303
|
+
params["title"] = opts[:title] if opts[:title]
|
|
304
|
+
params["x_label"] = opts[:x_label] if opts[:x_label]
|
|
305
|
+
params["y_label"] = opts[:y_label] if opts[:y_label]
|
|
306
|
+
params["width"] = opts[:width] if opts[:width]
|
|
307
|
+
params["height"] = opts[:height] if opts[:height]
|
|
308
|
+
warn_if_not_dg("chart")
|
|
309
|
+
ensure_initialized!
|
|
310
|
+
call_dg_tool("data-grout/prism.chart", params)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Generate a document toward a natural-language goal.
|
|
314
|
+
def render(goal:, payload: nil, format: "markdown", sections: nil, **opts)
|
|
315
|
+
params = { "goal" => goal, "format" => format }.merge(normalize_hash(opts))
|
|
316
|
+
params["payload"] = payload if payload
|
|
317
|
+
params["sections"] = sections if sections
|
|
318
|
+
warn_if_not_dg("render")
|
|
319
|
+
ensure_initialized!
|
|
320
|
+
call_dg_tool("data-grout/prism.render", params)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Convert content to another format (no LLM). Supports csv, xlsx, pdf, json, etc.
|
|
324
|
+
def export(content:, format:, style: nil, metadata: nil, **opts)
|
|
325
|
+
params = { "content" => content, "format" => format }.merge(normalize_hash(opts))
|
|
326
|
+
params["style"] = style if style
|
|
327
|
+
params["metadata"] = metadata if metadata
|
|
328
|
+
warn_if_not_dg("export")
|
|
329
|
+
ensure_initialized!
|
|
330
|
+
call_dg_tool("data-grout/prism.export", params)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Pause workflow for human approval.
|
|
334
|
+
def request_approval(action:, details: nil, reason: nil, context: nil, **opts)
|
|
335
|
+
params = { "action" => action }.merge(normalize_hash(opts))
|
|
336
|
+
params["details"] = details if details
|
|
337
|
+
params["reason"] = reason if reason
|
|
338
|
+
params["context"] = context if context
|
|
339
|
+
warn_if_not_dg("request_approval")
|
|
340
|
+
ensure_initialized!
|
|
341
|
+
call_dg_tool("data-grout/flow.request-approval", params)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Request user clarification for missing fields.
|
|
345
|
+
def request_feedback(missing_fields:, reason:, current_data: nil, suggestions: nil, context: nil, **opts)
|
|
346
|
+
params = { "missing_fields" => missing_fields, "reason" => reason }.merge(normalize_hash(opts))
|
|
347
|
+
params["current_data"] = current_data if current_data
|
|
348
|
+
params["suggestions"] = suggestions if suggestions
|
|
349
|
+
params["context"] = context if context
|
|
350
|
+
warn_if_not_dg("request_feedback")
|
|
351
|
+
ensure_initialized!
|
|
352
|
+
call_dg_tool("data-grout/flow.request-feedback", params)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# List recent tool executions for the current server.
|
|
356
|
+
def execution_history(limit: 50, offset: 0, status: nil, refractions_only: false, **opts)
|
|
357
|
+
params = { "limit" => limit, "offset" => offset, "refractions_only" => refractions_only }.merge(normalize_hash(opts))
|
|
358
|
+
params["status"] = status if status
|
|
359
|
+
warn_if_not_dg("execution_history")
|
|
360
|
+
ensure_initialized!
|
|
361
|
+
call_dg_tool("data-grout/inspect.execution-history", params)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Get details for a specific execution.
|
|
365
|
+
def execution_details(execution_id:)
|
|
366
|
+
params = { "execution_id" => execution_id }
|
|
367
|
+
warn_if_not_dg("execution_details")
|
|
368
|
+
ensure_initialized!
|
|
369
|
+
call_dg_tool("data-grout/inspect.execution-details", params)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# ================================================================
|
|
373
|
+
# Logic Cell Methods
|
|
374
|
+
# ================================================================
|
|
375
|
+
|
|
376
|
+
# Assert a fact or statement into the logic cell.
|
|
377
|
+
def remember(statement: nil, facts: nil, tag: nil)
|
|
378
|
+
raise ArgumentError, "must provide statement or facts" unless statement || facts
|
|
379
|
+
|
|
380
|
+
params = {}
|
|
381
|
+
params["statement"] = statement if statement
|
|
382
|
+
params["facts"] = facts if facts
|
|
383
|
+
params["tag"] = tag if tag
|
|
384
|
+
warn_if_not_dg("remember")
|
|
385
|
+
ensure_initialized!
|
|
386
|
+
call_dg_tool("data-grout/logic.remember", params)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Query the logic cell by question or patterns.
|
|
390
|
+
def query_cell(question: nil, patterns: nil, limit: nil)
|
|
391
|
+
raise ArgumentError, "must provide question or patterns" unless question || patterns
|
|
392
|
+
|
|
393
|
+
params = {}
|
|
394
|
+
params["question"] = question if question
|
|
395
|
+
params["patterns"] = patterns if patterns
|
|
396
|
+
params["limit"] = limit if limit
|
|
397
|
+
warn_if_not_dg("query_cell")
|
|
398
|
+
ensure_initialized!
|
|
399
|
+
call_dg_tool("data-grout/logic.query", params)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Remove facts from the logic cell by handles or pattern.
|
|
403
|
+
def forget(handles: nil, pattern: nil)
|
|
404
|
+
raise ArgumentError, "must provide handles or pattern" unless handles || pattern
|
|
405
|
+
|
|
406
|
+
params = {}
|
|
407
|
+
params["handles"] = handles if handles
|
|
408
|
+
params["pattern"] = pattern if pattern
|
|
409
|
+
warn_if_not_dg("forget")
|
|
410
|
+
ensure_initialized!
|
|
411
|
+
call_dg_tool("data-grout/logic.forget", params)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Assert a constraint rule into the logic cell.
|
|
415
|
+
def constrain(rule:, tag: nil)
|
|
416
|
+
params = { "rule" => rule }
|
|
417
|
+
params["tag"] = tag if tag
|
|
418
|
+
warn_if_not_dg("constrain")
|
|
419
|
+
ensure_initialized!
|
|
420
|
+
call_dg_tool("data-grout/logic.constrain", params)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Reflect on known facts about an entity.
|
|
424
|
+
def reflect(entity: nil, summary_only: false)
|
|
425
|
+
params = { "summary_only" => summary_only }
|
|
426
|
+
params["entity"] = entity if entity
|
|
427
|
+
warn_if_not_dg("reflect")
|
|
428
|
+
ensure_initialized!
|
|
429
|
+
call_dg_tool("data-grout/logic.reflect", params)
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Call any DataGrout first-party tool by short name.
|
|
433
|
+
# e.g. client.dg("prism.render", { payload: data, goal: "summary" })
|
|
434
|
+
def dg(tool_short_name, params = {})
|
|
435
|
+
ensure_initialized!
|
|
436
|
+
call_dg_tool("data-grout/#{tool_short_name}", params)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Estimate cost before execution.
|
|
440
|
+
def estimate_cost(tool_name, arguments = {})
|
|
441
|
+
ensure_initialized!
|
|
442
|
+
|
|
443
|
+
args = normalize_hash(arguments).merge("estimate_only" => true)
|
|
444
|
+
call_dg_tool(tool_name.to_s, args)
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
private
|
|
448
|
+
|
|
449
|
+
def ensure_initialized!
|
|
450
|
+
raise NotInitializedError unless @initialized
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# Route a DataGrout first-party tool call through the standard MCP
|
|
454
|
+
# `tools/call` path. Both the MCP endpoint (/mcp) and the JSONRPC
|
|
455
|
+
# endpoint (/rpc) dispatch on `tools/call`; the tool name goes in
|
|
456
|
+
# `params["name"]` and the tool arguments in `params["arguments"]`.
|
|
457
|
+
# The server resolves both versioned and unversioned tool names.
|
|
458
|
+
def call_dg_tool(tool_name, arguments)
|
|
459
|
+
params = { "name" => tool_name.to_s, "arguments" => normalize_hash(arguments) }
|
|
460
|
+
response = send_with_retry("tools/call", params)
|
|
461
|
+
raw = response.is_a?(Hash) ? (response["result"] || response) : response
|
|
462
|
+
unwrap_content(raw)
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# Unwrap the MCP content envelope that wraps tool results from both MCP and
|
|
466
|
+
# JSONRPC transports: {"content" => [{"type" => "text", "text" => "<json>"}]}
|
|
467
|
+
def unwrap_content(raw)
|
|
468
|
+
return raw unless raw.is_a?(Hash)
|
|
469
|
+
|
|
470
|
+
content = raw["content"]
|
|
471
|
+
return raw unless content.is_a?(Array) && !content.empty?
|
|
472
|
+
|
|
473
|
+
first = content.first
|
|
474
|
+
return raw unless first.is_a?(Hash) && first["text"].is_a?(String)
|
|
475
|
+
|
|
476
|
+
begin
|
|
477
|
+
JSON.parse(first["text"])
|
|
478
|
+
rescue JSON::ParserError
|
|
479
|
+
{ "text" => first["text"] }
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def send_with_retry(method, params)
|
|
484
|
+
retries = @max_retries
|
|
485
|
+
|
|
486
|
+
loop do
|
|
487
|
+
response = @transport.send_request(method, params)
|
|
488
|
+
return response
|
|
489
|
+
rescue McpError => e
|
|
490
|
+
if not_initialized_error?(e) && retries > 0
|
|
491
|
+
@logger.warn { "Server not initialized, retrying (#{retries} left)..." }
|
|
492
|
+
connect
|
|
493
|
+
retries -= 1
|
|
494
|
+
sleep 0.5
|
|
495
|
+
else
|
|
496
|
+
raise
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def not_initialized_error?(error)
|
|
502
|
+
return true if error.code == McpCodes::NOT_INITIALIZED
|
|
503
|
+
return true if error.message.include?("not initialized")
|
|
504
|
+
|
|
505
|
+
false
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def build_transport
|
|
509
|
+
case @transport_mode
|
|
510
|
+
when :mcp, "mcp"
|
|
511
|
+
Transport::Mcp.new(url: @url, auth: @auth, identity: @identity)
|
|
512
|
+
when :jsonrpc, "jsonrpc"
|
|
513
|
+
# When the user passes an MCP URL (ending in /mcp) and selects JSONRPC
|
|
514
|
+
# transport, transparently rewrite the path to the DG JSONRPC endpoint.
|
|
515
|
+
rpc_url = @url.end_with?("/mcp") ? @url.sub(%r{/mcp$}, "/rpc") : @url
|
|
516
|
+
Transport::JsonRpc.new(url: rpc_url, auth: @auth, identity: @identity)
|
|
517
|
+
else
|
|
518
|
+
raise ConfigError, "Unknown transport: #{@transport_mode}. Use :mcp or :jsonrpc."
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def resolve_identity!
|
|
523
|
+
return if @identity
|
|
524
|
+
return if @disable_mtls
|
|
525
|
+
return unless @is_dg
|
|
526
|
+
|
|
527
|
+
@identity = Identity.try_discover(override_dir: @identity_dir)
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def warn_if_not_dg(method_name)
|
|
531
|
+
return if @is_dg
|
|
532
|
+
return if @dg_warned
|
|
533
|
+
|
|
534
|
+
@dg_warned = true
|
|
535
|
+
@logger.warn do
|
|
536
|
+
"[conduit] `#{method_name}` is a DataGrout-specific extension. " \
|
|
537
|
+
"The connected server may not support it. " \
|
|
538
|
+
"Standard MCP methods (list_tools, call_tool, ...) work on any server."
|
|
539
|
+
end
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def normalize_hash(hash)
|
|
543
|
+
return {} if hash.nil?
|
|
544
|
+
|
|
545
|
+
hash.each_with_object({}) do |(k, v), memo|
|
|
546
|
+
memo[k.to_s] = v
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def default_logger
|
|
551
|
+
logger = Logger.new($stderr)
|
|
552
|
+
logger.level = Logger::WARN
|
|
553
|
+
logger.progname = "conduit"
|
|
554
|
+
logger
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# Wrapper around an active guided workflow session.
|
|
559
|
+
class GuidedSession
|
|
560
|
+
attr_reader :state
|
|
561
|
+
|
|
562
|
+
def initialize(client, state)
|
|
563
|
+
@client = client
|
|
564
|
+
@state = state
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
def session_id
|
|
568
|
+
@state.session_id
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def status
|
|
572
|
+
@state.status
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def options
|
|
576
|
+
@state.options
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def result
|
|
580
|
+
@state.result
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
def step
|
|
584
|
+
@state.step
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Make a choice and advance the workflow.
|
|
588
|
+
def choose(option_id)
|
|
589
|
+
@client.guide(session_id: session_id, choice: option_id.to_s)
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
# Check if the workflow is completed and return the result.
|
|
593
|
+
def complete
|
|
594
|
+
if status == "completed" && result
|
|
595
|
+
return result
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
raise Error, "Workflow not complete (status: #{status}). Call choose() with an option."
|
|
599
|
+
end
|
|
600
|
+
end
|
|
601
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DatagroutConduit
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class AuthError < Error; end
|
|
7
|
+
|
|
8
|
+
class ConnectionError < Error; end
|
|
9
|
+
|
|
10
|
+
class InitializationError < Error; end
|
|
11
|
+
|
|
12
|
+
class TimeoutError < Error; end
|
|
13
|
+
|
|
14
|
+
class ConfigError < Error; end
|
|
15
|
+
|
|
16
|
+
class NotInitializedError < Error
|
|
17
|
+
def initialize(msg = "Session not initialized. Call connect() first.")
|
|
18
|
+
super
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class ToolNotFoundError < Error; end
|
|
23
|
+
|
|
24
|
+
class ResourceNotFoundError < Error; end
|
|
25
|
+
|
|
26
|
+
class InvalidArgumentsError < Error; end
|
|
27
|
+
|
|
28
|
+
class McpError < Error
|
|
29
|
+
attr_reader :code, :data
|
|
30
|
+
|
|
31
|
+
def initialize(code:, message:, data: nil)
|
|
32
|
+
@code = code
|
|
33
|
+
@data = data
|
|
34
|
+
super("MCP error #{code}: #{message}")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class RateLimitedError < Error
|
|
39
|
+
attr_reader :used, :limit
|
|
40
|
+
|
|
41
|
+
def initialize(used:, limit:)
|
|
42
|
+
@used = used
|
|
43
|
+
@limit = limit
|
|
44
|
+
super("Rate limit exceeded (#{used} / #{limit} calls this hour)")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
module McpCodes
|
|
49
|
+
PARSE_ERROR = -32_700
|
|
50
|
+
INVALID_REQUEST = -32_600
|
|
51
|
+
METHOD_NOT_FOUND = -32_601
|
|
52
|
+
INVALID_PARAMS = -32_602
|
|
53
|
+
INTERNAL_ERROR = -32_603
|
|
54
|
+
NOT_INITIALIZED = -32_002
|
|
55
|
+
end
|
|
56
|
+
end
|