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.
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DatagroutConduit
4
+ VERSION = "0.1.0"
5
+ 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: []