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,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DatagroutConduit
|
|
4
|
+
module Transport
|
|
5
|
+
# MCP Streamable HTTP transport.
|
|
6
|
+
# Sends JSON-RPC requests via HTTP POST to the MCP endpoint.
|
|
7
|
+
class Mcp < Base
|
|
8
|
+
def initialize(url:, auth: {}, identity: nil)
|
|
9
|
+
super
|
|
10
|
+
@session_id = nil
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def send_request(method, params = nil, id: nil)
|
|
14
|
+
ensure_connected!
|
|
15
|
+
|
|
16
|
+
request_id = id || next_id
|
|
17
|
+
body = build_jsonrpc_body(method, params, request_id)
|
|
18
|
+
headers = build_headers
|
|
19
|
+
|
|
20
|
+
response = @connection.post do |req|
|
|
21
|
+
req.headers = headers
|
|
22
|
+
req.body = JSON.generate(body)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
track_session_id(response)
|
|
26
|
+
result = handle_response(response)
|
|
27
|
+
|
|
28
|
+
if result == :retry_oauth
|
|
29
|
+
headers = build_headers
|
|
30
|
+
response = @connection.post do |req|
|
|
31
|
+
req.headers = headers
|
|
32
|
+
req.body = JSON.generate(body)
|
|
33
|
+
end
|
|
34
|
+
track_session_id(response)
|
|
35
|
+
result = handle_response(response)
|
|
36
|
+
raise AuthError, "OAuth token rejected after refresh" if result == :retry_oauth
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
result
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def build_headers
|
|
45
|
+
headers = super
|
|
46
|
+
headers["Accept"] = "application/json, text/event-stream"
|
|
47
|
+
headers["Mcp-Session-Id"] = @session_id if @session_id
|
|
48
|
+
headers
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def track_session_id(response)
|
|
52
|
+
sid = response.headers["mcp-session-id"]
|
|
53
|
+
@session_id = sid if sid
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def handle_response(response)
|
|
57
|
+
check_rate_limit!(response)
|
|
58
|
+
|
|
59
|
+
if response.status == 401 && @auth[:type] == :oauth
|
|
60
|
+
@auth[:provider].invalidate!
|
|
61
|
+
return :retry_oauth
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
return { "accepted" => true } if response.status == 202
|
|
65
|
+
|
|
66
|
+
unless response.success?
|
|
67
|
+
raise ConnectionError, "HTTP #{response.status} error"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
content_type = response.headers["content-type"].to_s
|
|
71
|
+
|
|
72
|
+
if content_type.include?("text/event-stream")
|
|
73
|
+
messages = parse_sse(response.body.to_s)
|
|
74
|
+
body = messages.last || {}
|
|
75
|
+
else
|
|
76
|
+
body = response.body
|
|
77
|
+
body = JSON.parse(body) if body.is_a?(String)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if body.is_a?(Hash) && body["error"]
|
|
81
|
+
err = body["error"]
|
|
82
|
+
raise McpError.new(
|
|
83
|
+
code: err["code"] || -1,
|
|
84
|
+
message: err["message"] || "Unknown error",
|
|
85
|
+
data: err["data"]
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
body
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def parse_sse(body)
|
|
93
|
+
messages = []
|
|
94
|
+
body.split("\n\n").each do |chunk|
|
|
95
|
+
chunk.each_line do |line|
|
|
96
|
+
if line.start_with?("data:")
|
|
97
|
+
data = line.sub(/^data:\s*/, "").strip
|
|
98
|
+
next if data.empty?
|
|
99
|
+
messages << JSON.parse(data)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
messages
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DatagroutConduit
|
|
4
|
+
Tool = Struct.new(:name, :description, :input_schema, :annotations, keyword_init: true) do
|
|
5
|
+
def self.from_hash(hash)
|
|
6
|
+
hash = normalize_keys(hash)
|
|
7
|
+
new(
|
|
8
|
+
name: hash[:name],
|
|
9
|
+
description: hash[:description],
|
|
10
|
+
input_schema: hash[:input_schema] || hash[:inputschema],
|
|
11
|
+
annotations: hash[:annotations]
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.normalize_keys(hash)
|
|
16
|
+
hash.each_with_object({}) do |(k, v), memo|
|
|
17
|
+
memo[k.to_s.downcase.to_sym] = v
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
Byok = Struct.new(:enabled, :discount_applied, :discount_rate, keyword_init: true) do
|
|
23
|
+
def self.from_hash(hash)
|
|
24
|
+
return new(enabled: false, discount_applied: 0.0, discount_rate: 0.0) if hash.nil?
|
|
25
|
+
|
|
26
|
+
hash = hash.transform_keys(&:to_s)
|
|
27
|
+
new(
|
|
28
|
+
enabled: hash["enabled"] || false,
|
|
29
|
+
discount_applied: (hash["discount_applied"] || 0.0).to_f,
|
|
30
|
+
discount_rate: (hash["discount_rate"] || 0.0).to_f
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
Receipt = Struct.new(
|
|
36
|
+
:receipt_id, :transaction_id, :timestamp,
|
|
37
|
+
:estimated_credits, :actual_credits, :net_credits,
|
|
38
|
+
:savings, :savings_bonus,
|
|
39
|
+
:balance_before, :balance_after,
|
|
40
|
+
:breakdown, :byok,
|
|
41
|
+
keyword_init: true
|
|
42
|
+
) do
|
|
43
|
+
def self.from_hash(hash)
|
|
44
|
+
return nil if hash.nil?
|
|
45
|
+
|
|
46
|
+
hash = hash.transform_keys(&:to_s)
|
|
47
|
+
new(
|
|
48
|
+
receipt_id: hash["receipt_id"],
|
|
49
|
+
transaction_id: hash["transaction_id"],
|
|
50
|
+
timestamp: hash["timestamp"],
|
|
51
|
+
estimated_credits: hash["estimated_credits"]&.to_f,
|
|
52
|
+
actual_credits: hash["actual_credits"]&.to_f,
|
|
53
|
+
net_credits: hash["net_credits"]&.to_f,
|
|
54
|
+
savings: (hash["savings"] || 0.0).to_f,
|
|
55
|
+
savings_bonus: (hash["savings_bonus"] || 0.0).to_f,
|
|
56
|
+
balance_before: hash["balance_before"]&.to_f,
|
|
57
|
+
balance_after: hash["balance_after"]&.to_f,
|
|
58
|
+
breakdown: hash["breakdown"] || {},
|
|
59
|
+
byok: Byok.from_hash(hash["byok"])
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
CreditEstimate = Struct.new(
|
|
65
|
+
:estimated_total, :actual_total, :net_total, :breakdown,
|
|
66
|
+
keyword_init: true
|
|
67
|
+
) do
|
|
68
|
+
def self.from_hash(hash)
|
|
69
|
+
return nil if hash.nil?
|
|
70
|
+
|
|
71
|
+
hash = hash.transform_keys(&:to_s)
|
|
72
|
+
new(
|
|
73
|
+
estimated_total: hash["estimated_total"]&.to_f,
|
|
74
|
+
actual_total: hash["actual_total"]&.to_f,
|
|
75
|
+
net_total: hash["net_total"]&.to_f,
|
|
76
|
+
breakdown: hash["breakdown"] || {}
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
ToolMeta = Struct.new(:receipt, :credit_estimate, keyword_init: true) do
|
|
82
|
+
def self.from_hash(hash)
|
|
83
|
+
return nil if hash.nil?
|
|
84
|
+
|
|
85
|
+
hash = hash.transform_keys(&:to_s)
|
|
86
|
+
new(
|
|
87
|
+
receipt: Receipt.from_hash(hash["receipt"]),
|
|
88
|
+
credit_estimate: CreditEstimate.from_hash(hash["credit_estimate"])
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
DiscoveredTool = Struct.new(:name, :description, :input_schema, :score, :integration, :server, keyword_init: true) do
|
|
94
|
+
def self.from_hash(hash)
|
|
95
|
+
hash = hash.transform_keys(&:to_s)
|
|
96
|
+
# DG returns "tool_name" (not "name") and "input_contract" (not "input_schema")
|
|
97
|
+
new(
|
|
98
|
+
name: hash["tool_name"] || hash["name"],
|
|
99
|
+
description: hash["description"],
|
|
100
|
+
input_schema: hash["input_contract"] || hash["input_schema"] || hash["inputSchema"],
|
|
101
|
+
score: hash["score"]&.to_f,
|
|
102
|
+
integration: hash["integration"],
|
|
103
|
+
server: hash["server"]
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
DiscoverResult = Struct.new(:tools, :query, :total, keyword_init: true) do
|
|
109
|
+
def self.from_hash(hash)
|
|
110
|
+
hash = hash.transform_keys(&:to_s)
|
|
111
|
+
# DG returns "results" (not "tools") and "goal_used" (not "query")
|
|
112
|
+
tools_raw = hash["results"] || hash["tools"] || []
|
|
113
|
+
tools = tools_raw.map { |t| DiscoveredTool.from_hash(t) }
|
|
114
|
+
new(
|
|
115
|
+
tools: tools,
|
|
116
|
+
query: hash["goal_used"] || hash["query"],
|
|
117
|
+
total: hash["total"] || tools.size
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
GuideOption = Struct.new(:id, :label, :description, keyword_init: true) do
|
|
123
|
+
def self.from_hash(hash)
|
|
124
|
+
hash = hash.transform_keys(&:to_s)
|
|
125
|
+
new(id: hash["id"], label: hash["label"], description: hash["description"])
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
GuideState = Struct.new(:session_id, :status, :options, :result, :step, keyword_init: true) do
|
|
130
|
+
def self.from_hash(hash)
|
|
131
|
+
hash = hash.transform_keys(&:to_s)
|
|
132
|
+
opts = hash["options"]&.map { |o| GuideOption.from_hash(o) }
|
|
133
|
+
new(
|
|
134
|
+
session_id: hash["session_id"] || hash["sessionId"],
|
|
135
|
+
status: hash["status"],
|
|
136
|
+
options: opts,
|
|
137
|
+
result: hash["result"],
|
|
138
|
+
step: hash["step"]
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "datagrout_conduit/version"
|
|
6
|
+
require_relative "datagrout_conduit/errors"
|
|
7
|
+
require_relative "datagrout_conduit/types"
|
|
8
|
+
require_relative "datagrout_conduit/identity"
|
|
9
|
+
require_relative "datagrout_conduit/oauth"
|
|
10
|
+
require_relative "datagrout_conduit/registration"
|
|
11
|
+
require_relative "datagrout_conduit/transport/base"
|
|
12
|
+
require_relative "datagrout_conduit/transport/mcp"
|
|
13
|
+
require_relative "datagrout_conduit/transport/jsonrpc"
|
|
14
|
+
require_relative "datagrout_conduit/client"
|
|
15
|
+
|
|
16
|
+
module DatagroutConduit
|
|
17
|
+
DG_CA_URL = Registration::DG_CA_URL
|
|
18
|
+
DG_SUBSTRATE_ENDPOINT = Registration::DG_SUBSTRATE_ENDPOINT
|
|
19
|
+
|
|
20
|
+
# Returns true when +url+ points at a DataGrout-managed endpoint.
|
|
21
|
+
#
|
|
22
|
+
# Used to decide whether to auto-enable mTLS discovery and the intelligent
|
|
23
|
+
# interface, and whether to warn when DG-specific methods are called against
|
|
24
|
+
# a non-DG server.
|
|
25
|
+
def self.dg_url?(url)
|
|
26
|
+
url.to_s.include?("datagrout.ai") ||
|
|
27
|
+
url.to_s.include?("datagrout.dev") ||
|
|
28
|
+
ENV.key?("CONDUIT_IS_DG")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Extract the DataGrout metadata block from a tool-call result.
|
|
32
|
+
#
|
|
33
|
+
# Checks +_meta.datagrout+ first (current format), then +_datagrout+,
|
|
34
|
+
# then falls back to +_meta+ for backward compatibility with older
|
|
35
|
+
# gateway responses.
|
|
36
|
+
#
|
|
37
|
+
# Returns nil when the result contains neither key (e.g. upstream servers
|
|
38
|
+
# that don't go through the DG gateway).
|
|
39
|
+
#
|
|
40
|
+
# meta = DatagroutConduit.extract_meta(result)
|
|
41
|
+
# meta.receipt.net_credits #=> 1.5
|
|
42
|
+
# meta.receipt.receipt_id #=> "rcp_abc123"
|
|
43
|
+
def self.extract_meta(result)
|
|
44
|
+
return nil unless result.is_a?(Hash)
|
|
45
|
+
|
|
46
|
+
raw = result.dig("_meta", "datagrout") || result.dig(:_meta, :datagrout) ||
|
|
47
|
+
result["_datagrout"] || result[:_datagrout] ||
|
|
48
|
+
result["_meta"] || result[:_meta]
|
|
49
|
+
return nil if raw.nil?
|
|
50
|
+
|
|
51
|
+
ToolMeta.from_hash(raw)
|
|
52
|
+
end
|
|
53
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: datagrout-conduit
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- DataGrout
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-03-03 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: faraday
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '2.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '2.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: faraday-multipart
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '1.0'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '1.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: base64
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '0'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: minitest
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '5.0'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '5.0'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: rake
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '13.0'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '13.0'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: webmock
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - "~>"
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '3.0'
|
|
90
|
+
type: :development
|
|
91
|
+
prerelease: false
|
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - "~>"
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '3.0'
|
|
97
|
+
description: Production-ready MCP client with mTLS, OAuth 2.1, and semantic discovery.
|
|
98
|
+
Connect to remote MCP and JSONRPC servers, invoke tools, discover capabilities with
|
|
99
|
+
natural language, and track costs — all with enterprise-grade security.
|
|
100
|
+
email:
|
|
101
|
+
- hello@datagrout.ai
|
|
102
|
+
executables: []
|
|
103
|
+
extensions: []
|
|
104
|
+
extra_rdoc_files: []
|
|
105
|
+
files:
|
|
106
|
+
- README.md
|
|
107
|
+
- lib/datagrout_conduit.rb
|
|
108
|
+
- lib/datagrout_conduit/client.rb
|
|
109
|
+
- lib/datagrout_conduit/errors.rb
|
|
110
|
+
- lib/datagrout_conduit/identity.rb
|
|
111
|
+
- lib/datagrout_conduit/oauth.rb
|
|
112
|
+
- lib/datagrout_conduit/registration.rb
|
|
113
|
+
- lib/datagrout_conduit/transport/base.rb
|
|
114
|
+
- lib/datagrout_conduit/transport/jsonrpc.rb
|
|
115
|
+
- lib/datagrout_conduit/transport/mcp.rb
|
|
116
|
+
- lib/datagrout_conduit/types.rb
|
|
117
|
+
- lib/datagrout_conduit/version.rb
|
|
118
|
+
homepage: https://github.com/DataGrout/conduit-sdk
|
|
119
|
+
licenses:
|
|
120
|
+
- MIT
|
|
121
|
+
metadata:
|
|
122
|
+
homepage_uri: https://github.com/DataGrout/conduit-sdk
|
|
123
|
+
source_code_uri: https://github.com/DataGrout/conduit-sdk/tree/main/ruby
|
|
124
|
+
changelog_uri: https://github.com/DataGrout/conduit-sdk/blob/main/CHANGELOG.md
|
|
125
|
+
bug_tracker_uri: https://github.com/DataGrout/conduit-sdk/issues
|
|
126
|
+
post_install_message:
|
|
127
|
+
rdoc_options: []
|
|
128
|
+
require_paths:
|
|
129
|
+
- lib
|
|
130
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
131
|
+
requirements:
|
|
132
|
+
- - ">="
|
|
133
|
+
- !ruby/object:Gem::Version
|
|
134
|
+
version: 2.6.0
|
|
135
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
136
|
+
requirements:
|
|
137
|
+
- - ">="
|
|
138
|
+
- !ruby/object:Gem::Version
|
|
139
|
+
version: '0'
|
|
140
|
+
requirements: []
|
|
141
|
+
rubygems_version: 3.5.22
|
|
142
|
+
signing_key:
|
|
143
|
+
specification_version: 4
|
|
144
|
+
summary: Production-ready MCP client with mTLS, OAuth 2.1, and semantic discovery
|
|
145
|
+
test_files: []
|