ruby-utcp 1.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/CHANGELOG.md +11 -0
- data/LICENSE +22 -0
- data/Makefile +226 -0
- data/README.md +331 -0
- data/examples/basic.rb +33 -0
- data/examples/cli.rb +32 -0
- data/examples/generated/__init__.py +1 -0
- data/examples/generated/utcp_pb2.py +46 -0
- data/examples/generated/utcp_pb2_grpc.py +183 -0
- data/examples/graphql.rb +15 -0
- data/examples/grpc.rb +42 -0
- data/examples/grpc_python.py +52 -0
- data/examples/http.rb +17 -0
- data/examples/mcp.rb +28 -0
- data/examples/servers/graphql_server.rb +39 -0
- data/examples/servers/grpc_server.py +97 -0
- data/examples/servers/grpc_server.rb +62 -0
- data/examples/servers/http_helpers.rb +34 -0
- data/examples/servers/http_server.rb +28 -0
- data/examples/servers/mcp_stdio_server.rb +43 -0
- data/examples/servers/requirements-grpc.txt +2 -0
- data/examples/servers/sse_server.rb +36 -0
- data/examples/servers/streamable_http_server.rb +39 -0
- data/examples/servers/tcp_server.rb +58 -0
- data/examples/servers/udp_server.rb +33 -0
- data/examples/servers/webrtc_server.rb +78 -0
- data/examples/servers/websocket_server.rb +92 -0
- data/examples/sse.rb +16 -0
- data/examples/streamable_http.rb +17 -0
- data/examples/tcp.rb +20 -0
- data/examples/text.rb +23 -0
- data/examples/udp.rb +18 -0
- data/examples/webrtc.rb +19 -0
- data/examples/websocket.rb +17 -0
- data/lib/ruby-utcp.rb +4 -0
- data/lib/utcp/client.rb +217 -0
- data/lib/utcp/config.rb +79 -0
- data/lib/utcp/errors.rb +48 -0
- data/lib/utcp/migration.rb +88 -0
- data/lib/utcp/models.rb +794 -0
- data/lib/utcp/openapi_converter.rb +179 -0
- data/lib/utcp/protocols/base.rb +97 -0
- data/lib/utcp/protocols/cli.rb +186 -0
- data/lib/utcp/protocols/file.rb +52 -0
- data/lib/utcp/protocols/graphql.rb +277 -0
- data/lib/utcp/protocols/grpc.rb +207 -0
- data/lib/utcp/protocols/http.rb +340 -0
- data/lib/utcp/protocols/http_stream_support.rb +122 -0
- data/lib/utcp/protocols/mcp.rb +339 -0
- data/lib/utcp/protocols/socket_support.rb +51 -0
- data/lib/utcp/protocols/sse.rb +107 -0
- data/lib/utcp/protocols/streamable_http.rb +78 -0
- data/lib/utcp/protocols/tcp.rb +143 -0
- data/lib/utcp/protocols/text.rb +44 -0
- data/lib/utcp/protocols/udp.rb +61 -0
- data/lib/utcp/protocols/webrtc.rb +217 -0
- data/lib/utcp/protocols/websocket.rb +350 -0
- data/lib/utcp/registry.rb +67 -0
- data/lib/utcp/repository.rb +137 -0
- data/lib/utcp/serializer.rb +71 -0
- data/lib/utcp/utils.rb +118 -0
- data/lib/utcp/variables.rb +170 -0
- data/lib/utcp/version.rb +6 -0
- data/lib/utcp.rb +70 -0
- data/proto/utcp.proto +31 -0
- metadata +148 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UTCP
|
|
4
|
+
class GraphQLProtocol < HTTPProtocol
|
|
5
|
+
INTROSPECTION_QUERY = <<~GRAPHQL.freeze
|
|
6
|
+
query UTCPIntrospection {
|
|
7
|
+
__schema {
|
|
8
|
+
queryType { name fields { name description args { name description defaultValue type { kind name ofType { kind name ofType { kind name ofType { kind name } } } } } type { kind name ofType { kind name ofType { kind name ofType { kind name } } } } } }
|
|
9
|
+
mutationType { name fields { name description args { name description defaultValue type { kind name ofType { kind name ofType { kind name ofType { kind name } } } } type { kind name ofType { kind name ofType { kind name ofType { kind name } } } } } }
|
|
10
|
+
subscriptionType { name fields { name description args { name description defaultValue type { kind name ofType { kind name ofType { kind name ofType { kind name } } } } type { kind name ofType { kind name ofType { kind name ofType { kind name } } } } } }
|
|
11
|
+
types { kind name fields { name type { kind name ofType { kind name ofType { kind name } } } } enumValues { name } }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
GRAPHQL
|
|
15
|
+
|
|
16
|
+
def initialize(websocket_factory: nil, **options)
|
|
17
|
+
super(**options)
|
|
18
|
+
@websocket_factory = websocket_factory || lambda do |url, headers, protocol, timeout|
|
|
19
|
+
WebSocketConnection.new(url, headers, protocol, timeout)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def register_manual(client, template)
|
|
24
|
+
assert_graphql_template!(template)
|
|
25
|
+
result = graphql_request(template, "query" => INTROSPECTION_QUERY, "variables" => {})
|
|
26
|
+
schema = result.dig("data", "__schema")
|
|
27
|
+
raise SerializerValidationError, "GraphQL introspection response does not contain data.__schema" unless schema
|
|
28
|
+
|
|
29
|
+
manual = Manual.new(
|
|
30
|
+
utcp_version: VERSION,
|
|
31
|
+
manual_version: "1.0.0",
|
|
32
|
+
info: { "title" => template.name, "version" => "1.0.0" },
|
|
33
|
+
tools: introspection_tools(template, schema)
|
|
34
|
+
)
|
|
35
|
+
success(template, manual)
|
|
36
|
+
rescue StandardError => error
|
|
37
|
+
client.logger.warn("Unable to register GraphQL manual #{template.name.inspect}: #{error.message}")
|
|
38
|
+
failure(template, error)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def call_tool(_client, tool_name, tool_args, template)
|
|
42
|
+
assert_graphql_template!(template)
|
|
43
|
+
if template.operation_type == "subscription"
|
|
44
|
+
values = []
|
|
45
|
+
call_subscription(tool_name, tool_args, template) { |value| values << value }
|
|
46
|
+
return values
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
args, headers = extract_header_arguments(template, tool_args)
|
|
50
|
+
payload = graphql_payload(tool_name, args, template)
|
|
51
|
+
response = graphql_request(template, payload, headers)
|
|
52
|
+
raise_graphql_errors!(response, tool_name)
|
|
53
|
+
extract_graphql_data(response, template.operation_name || unqualified_name(tool_name))
|
|
54
|
+
rescue Error
|
|
55
|
+
raise
|
|
56
|
+
rescue StandardError => error
|
|
57
|
+
raise ToolCallError.new("GraphQL tool #{tool_name.inspect} failed: #{error.message}", tool_name: tool_name)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def call_tool_streaming(_client, tool_name, tool_args, template)
|
|
61
|
+
return enum_for(__method__, _client, tool_name, tool_args, template) unless block_given?
|
|
62
|
+
|
|
63
|
+
if template.operation_type == "subscription"
|
|
64
|
+
call_subscription(tool_name, tool_args, template) { |value| yield value }
|
|
65
|
+
else
|
|
66
|
+
yield call_tool(_client, tool_name, tool_args, template)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def assert_graphql_template!(template)
|
|
73
|
+
return if template.is_a?(GraphQLCallTemplate)
|
|
74
|
+
|
|
75
|
+
raise ValidationError, "GraphQL protocol requires a GraphQLCallTemplate"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def graphql_request(template, payload, extra_headers = {})
|
|
79
|
+
headers = Utils.stringify_keys(template.headers || {}).merge(Utils.stringify_keys(extra_headers))
|
|
80
|
+
headers["Accept"] ||= "application/json"
|
|
81
|
+
query = {}
|
|
82
|
+
cookies = {}
|
|
83
|
+
sensitive = apply_auth(template.auth, headers, query, cookies)
|
|
84
|
+
if template.auth.is_a?(OAuth2Auth)
|
|
85
|
+
headers["Authorization"] = "Bearer #{oauth_token(template.auth)}"
|
|
86
|
+
sensitive << "Authorization"
|
|
87
|
+
end
|
|
88
|
+
uri = URLSecurity.validate!(append_query(template.url, query), context: "GraphQL request")
|
|
89
|
+
response = perform_request(
|
|
90
|
+
"POST", uri,
|
|
91
|
+
headers: headers,
|
|
92
|
+
cookies: cookies,
|
|
93
|
+
body: payload,
|
|
94
|
+
content_type: "application/json",
|
|
95
|
+
timeout: template.timeout,
|
|
96
|
+
sensitive_headers: sensitive.uniq
|
|
97
|
+
)
|
|
98
|
+
data = JSON.parse(response.body)
|
|
99
|
+
raise_graphql_errors!(data, template.operation_name || template.name)
|
|
100
|
+
data
|
|
101
|
+
rescue JSON::ParserError => error
|
|
102
|
+
raise SerializerValidationError, "Invalid GraphQL JSON response: #{error.message}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def introspection_tools(template, schema)
|
|
106
|
+
types = Array(schema["types"]).each_with_object({}) { |type, map| map[type["name"]] = type if type["name"] }
|
|
107
|
+
%w[query mutation subscription].flat_map do |kind|
|
|
108
|
+
root = schema["#{kind}Type"]
|
|
109
|
+
next [] unless root
|
|
110
|
+
|
|
111
|
+
Array(root["fields"]).map do |field|
|
|
112
|
+
args = Array(field["args"])
|
|
113
|
+
properties = args.each_with_object({}) do |argument, result|
|
|
114
|
+
result[argument["name"]] = schema_for_type(argument["type"], types).tap do |value|
|
|
115
|
+
value["description"] = argument["description"] if argument["description"]
|
|
116
|
+
value["default"] = argument["defaultValue"] if argument["defaultValue"]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
required = args.select { |argument| argument.dig("type", "kind") == "NON_NULL" }.map { |argument| argument["name"] }
|
|
120
|
+
selection = selection_for(field["type"], types)
|
|
121
|
+
call_template = template.to_h.merge(
|
|
122
|
+
"operation_type" => kind,
|
|
123
|
+
"operation_name" => field["name"],
|
|
124
|
+
"variable_types" => args.each_with_object({}) do |argument, result|
|
|
125
|
+
result[argument["name"]] = graphql_type_name(argument["type"])
|
|
126
|
+
end,
|
|
127
|
+
"selection_set" => selection
|
|
128
|
+
)
|
|
129
|
+
Tool.new(
|
|
130
|
+
name: field["name"],
|
|
131
|
+
description: field["description"].to_s,
|
|
132
|
+
inputs: Utils.compact_hash("type" => "object", "properties" => properties, "required" => required.empty? ? nil : required),
|
|
133
|
+
outputs: schema_for_type(field["type"], types),
|
|
134
|
+
tool_call_template: call_template
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def schema_for_type(type, types)
|
|
141
|
+
return {} unless type
|
|
142
|
+
|
|
143
|
+
case type["kind"]
|
|
144
|
+
when "NON_NULL"
|
|
145
|
+
schema_for_type(type["ofType"], types)
|
|
146
|
+
when "LIST"
|
|
147
|
+
{ "type" => "array", "items" => schema_for_type(type["ofType"], types) }
|
|
148
|
+
when "SCALAR"
|
|
149
|
+
{ "type" => scalar_json_type(type["name"]) }
|
|
150
|
+
when "ENUM"
|
|
151
|
+
values = Array(types.dig(type["name"], "enumValues")).map { |value| value["name"] }
|
|
152
|
+
Utils.compact_hash("type" => "string", "enum" => values.empty? ? nil : values)
|
|
153
|
+
when "OBJECT", "INPUT_OBJECT", "INTERFACE", "UNION"
|
|
154
|
+
{ "type" => "object" }
|
|
155
|
+
else
|
|
156
|
+
{}
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def scalar_json_type(name)
|
|
161
|
+
return "integer" if name == "Int"
|
|
162
|
+
return "number" if name == "Float"
|
|
163
|
+
return "boolean" if name == "Boolean"
|
|
164
|
+
|
|
165
|
+
"string"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def graphql_type_name(type)
|
|
169
|
+
return "String" unless type
|
|
170
|
+
|
|
171
|
+
case type["kind"]
|
|
172
|
+
when "NON_NULL" then "#{graphql_type_name(type["ofType"])}!"
|
|
173
|
+
when "LIST" then "[#{graphql_type_name(type["ofType"])}]"
|
|
174
|
+
else type["name"] || "String"
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def selection_for(type, types)
|
|
179
|
+
inner = type
|
|
180
|
+
inner = inner["ofType"] while inner && %w[NON_NULL LIST].include?(inner["kind"])
|
|
181
|
+
return nil unless inner && %w[OBJECT INTERFACE UNION].include?(inner["kind"])
|
|
182
|
+
|
|
183
|
+
fields = Array(types.dig(inner["name"], "fields")).select do |field|
|
|
184
|
+
child = field["type"]
|
|
185
|
+
child = child["ofType"] while child && child["kind"] == "NON_NULL"
|
|
186
|
+
child && %w[SCALAR ENUM].include?(child["kind"])
|
|
187
|
+
end.map { |field| field["name"] }.first(20)
|
|
188
|
+
fields.empty? ? "__typename" : fields.join(" ")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def graphql_payload(tool_name, arguments, template)
|
|
192
|
+
return { "query" => template.query, "variables" => arguments } if template.query
|
|
193
|
+
|
|
194
|
+
field = template.operation_name || unqualified_name(tool_name)
|
|
195
|
+
variable_types = arguments.each_with_object({}) do |(name, _value), result|
|
|
196
|
+
result[name] = template.variable_types[name] || "String"
|
|
197
|
+
end
|
|
198
|
+
declarations = variable_types.map { |name, type| "$#{name}: #{type}" }.join(", ")
|
|
199
|
+
invocation = variable_types.keys.map { |name| "#{name}: $#{name}" }.join(", ")
|
|
200
|
+
operation_label = "UTCP_#{field.gsub(/[^A-Za-z0-9_]/, "_")}"
|
|
201
|
+
query = +"#{template.operation_type} #{operation_label}"
|
|
202
|
+
query << "(#{declarations})" unless declarations.empty?
|
|
203
|
+
query << " { #{field}"
|
|
204
|
+
query << "(#{invocation})" unless invocation.empty?
|
|
205
|
+
query << " { #{template.selection_set} }" if template.selection_set && !template.selection_set.empty?
|
|
206
|
+
query << " }"
|
|
207
|
+
{ "query" => query, "variables" => arguments, "operationName" => operation_label }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def extract_header_arguments(template, arguments)
|
|
211
|
+
args = Utils.stringify_keys(arguments || {})
|
|
212
|
+
headers = {}
|
|
213
|
+
template.header_fields.each { |field| headers[field] = args.delete(field).to_s if args.key?(field) }
|
|
214
|
+
[args, headers]
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def raise_graphql_errors!(response, tool_name)
|
|
218
|
+
errors = response["errors"] if response.is_a?(Hash)
|
|
219
|
+
return if errors.nil? || errors.empty?
|
|
220
|
+
|
|
221
|
+
detail = errors.map { |error| error.is_a?(Hash) ? error["message"] : error.to_s }.join("; ")
|
|
222
|
+
raise ToolCallError.new("GraphQL tool #{tool_name.inspect} returned errors: #{detail}", tool_name: tool_name,
|
|
223
|
+
response_body: JSON.generate(errors))
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def extract_graphql_data(response, field)
|
|
227
|
+
data = response["data"]
|
|
228
|
+
data.is_a?(Hash) && data.key?(field) ? data[field] : data
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def unqualified_name(tool_name)
|
|
232
|
+
tool_name.to_s.split(".").last
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def call_subscription(tool_name, tool_args, template)
|
|
236
|
+
args, headers = extract_header_arguments(template, tool_args)
|
|
237
|
+
query = {}
|
|
238
|
+
cookies = {}
|
|
239
|
+
apply_auth(template.auth, headers, query, cookies)
|
|
240
|
+
headers["Authorization"] = "Bearer #{oauth_token(template.auth)}" if template.auth.is_a?(OAuth2Auth)
|
|
241
|
+
headers["Cookie"] = cookies.map { |key, value| "#{key}=#{value}" }.join("; ") unless cookies.empty?
|
|
242
|
+
http_uri = URLSecurity.validate!(append_query(template.url, query), context: "GraphQL subscription")
|
|
243
|
+
ws_scheme = http_uri.scheme == "https" ? "wss" : "ws"
|
|
244
|
+
ws_url = http_uri.to_s.sub(/\Ahttps?/, ws_scheme)
|
|
245
|
+
connection = @websocket_factory.call(ws_url, headers, "graphql-transport-ws", template.timeout)
|
|
246
|
+
identifier = SecureRandom.uuid
|
|
247
|
+
connection.send_text(JSON.generate("type" => "connection_init", "payload" => {}))
|
|
248
|
+
ack = connection.read_message
|
|
249
|
+
ack_data = ack && decode_json_or_text(ack[1].force_encoding(Encoding::UTF_8))
|
|
250
|
+
unless ack_data.is_a?(Hash) && ack_data["type"] == "connection_ack"
|
|
251
|
+
raise ToolCallError, "GraphQL subscription did not receive connection_ack"
|
|
252
|
+
end
|
|
253
|
+
connection.send_text(JSON.generate(
|
|
254
|
+
"id" => identifier,
|
|
255
|
+
"type" => "subscribe",
|
|
256
|
+
"payload" => graphql_payload(tool_name, args, template)
|
|
257
|
+
))
|
|
258
|
+
while (frame = connection.read_message)
|
|
259
|
+
message = decode_json_or_text(frame[1].force_encoding(Encoding::UTF_8))
|
|
260
|
+
next unless message.is_a?(Hash) && message["id"] == identifier
|
|
261
|
+
break if message["type"] == "complete"
|
|
262
|
+
if message["type"] == "error"
|
|
263
|
+
raise ToolCallError, "GraphQL subscription failed: #{message["payload"].inspect}"
|
|
264
|
+
end
|
|
265
|
+
next unless message["type"] == "next"
|
|
266
|
+
|
|
267
|
+
payload = message["payload"] || {}
|
|
268
|
+
raise_graphql_errors!(payload, tool_name)
|
|
269
|
+
yield extract_graphql_data(payload, template.operation_name || unqualified_name(tool_name))
|
|
270
|
+
end
|
|
271
|
+
ensure
|
|
272
|
+
connection.close if defined?(connection) && connection
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
GraphqlCommunicationProtocol = GraphQLProtocol
|
|
276
|
+
GraphQLCommunicationProtocol = GraphQLProtocol
|
|
277
|
+
end
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UTCP
|
|
4
|
+
module ProtobufWire
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def string_field(number, value)
|
|
8
|
+
bytes = value.to_s.b
|
|
9
|
+
varint((number << 3) | 2) + varint(bytes.bytesize) + bytes
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def varint(value)
|
|
13
|
+
number = Integer(value)
|
|
14
|
+
result = +"".b
|
|
15
|
+
loop do
|
|
16
|
+
byte = number & 0x7F
|
|
17
|
+
number >>= 7
|
|
18
|
+
byte |= 0x80 unless number.zero?
|
|
19
|
+
result << byte
|
|
20
|
+
break if number.zero?
|
|
21
|
+
end
|
|
22
|
+
result
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def fields(bytes)
|
|
26
|
+
data = bytes.to_s.b
|
|
27
|
+
offset = 0
|
|
28
|
+
result = Hash.new { |hash, key| hash[key] = [] }
|
|
29
|
+
while offset < data.bytesize
|
|
30
|
+
tag, offset = read_varint(data, offset)
|
|
31
|
+
number = tag >> 3
|
|
32
|
+
wire = tag & 7
|
|
33
|
+
case wire
|
|
34
|
+
when 0
|
|
35
|
+
value, offset = read_varint(data, offset)
|
|
36
|
+
when 1
|
|
37
|
+
value = data.byteslice(offset, 8)
|
|
38
|
+
offset += 8
|
|
39
|
+
when 2
|
|
40
|
+
length, offset = read_varint(data, offset)
|
|
41
|
+
raise SerializerValidationError, "truncated protobuf field" if offset + length > data.bytesize
|
|
42
|
+
value = data.byteslice(offset, length)
|
|
43
|
+
offset += length
|
|
44
|
+
when 5
|
|
45
|
+
value = data.byteslice(offset, 4)
|
|
46
|
+
offset += 4
|
|
47
|
+
else
|
|
48
|
+
raise SerializerValidationError, "unsupported protobuf wire type #{wire}"
|
|
49
|
+
end
|
|
50
|
+
result[number] << value
|
|
51
|
+
end
|
|
52
|
+
result
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def read_varint(data, offset)
|
|
56
|
+
value = 0
|
|
57
|
+
shift = 0
|
|
58
|
+
loop do
|
|
59
|
+
raise SerializerValidationError, "truncated protobuf varint" if offset >= data.bytesize
|
|
60
|
+
byte = data.getbyte(offset)
|
|
61
|
+
offset += 1
|
|
62
|
+
value |= (byte & 0x7F) << shift
|
|
63
|
+
return [value, offset] if (byte & 0x80).zero?
|
|
64
|
+
shift += 7
|
|
65
|
+
raise SerializerValidationError, "protobuf varint is too long" if shift > 63
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
class GRPCGemClient
|
|
71
|
+
def initialize(template)
|
|
72
|
+
require "grpc"
|
|
73
|
+
address = "#{template.host}:#{template.port}"
|
|
74
|
+
credentials = template.use_ssl ? GRPC::Core::ChannelCredentials.new : :this_channel_is_insecure
|
|
75
|
+
@stub = GRPC::ClientStub.new(address, credentials)
|
|
76
|
+
rescue LoadError => error
|
|
77
|
+
raise MissingDependencyError,
|
|
78
|
+
"gRPC requires the optional 'grpc' gem (add gem \"grpc\" to your Gemfile): #{error.message}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def unary(route, payload, timeout:, metadata: {})
|
|
82
|
+
@stub.request_response(
|
|
83
|
+
route, payload,
|
|
84
|
+
->(value) { value.to_s.b }, ->(bytes) { bytes },
|
|
85
|
+
deadline: Time.now + timeout,
|
|
86
|
+
metadata: metadata
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def server_stream(route, payload, timeout:, metadata: {})
|
|
91
|
+
@stub.server_streamer(
|
|
92
|
+
route, payload,
|
|
93
|
+
->(value) { value.to_s.b }, ->(bytes) { bytes },
|
|
94
|
+
deadline: Time.now + timeout,
|
|
95
|
+
metadata: metadata
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
class GRPCProtocol < HTTPProtocol
|
|
101
|
+
def initialize(rpc_client_factory: nil, **options)
|
|
102
|
+
super(**options)
|
|
103
|
+
@rpc_client_factory = rpc_client_factory || ->(template) { GRPCGemClient.new(template) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def register_manual(client, template)
|
|
107
|
+
assert_grpc_template!(template)
|
|
108
|
+
response = rpc_client(template).unary(
|
|
109
|
+
route(template, "GetManual"), "".b,
|
|
110
|
+
timeout: template.timeout,
|
|
111
|
+
metadata: grpc_metadata(template)
|
|
112
|
+
)
|
|
113
|
+
fields = ProtobufWire.fields(response)
|
|
114
|
+
tools = fields[2].map do |tool_bytes|
|
|
115
|
+
tool_fields = ProtobufWire.fields(tool_bytes)
|
|
116
|
+
{
|
|
117
|
+
"name" => tool_fields[1].first.to_s.force_encoding(Encoding::UTF_8),
|
|
118
|
+
"description" => tool_fields[2].first.to_s.force_encoding(Encoding::UTF_8),
|
|
119
|
+
"tool_call_template" => template.to_h
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
manual = Manual.new(
|
|
123
|
+
utcp_version: VERSION,
|
|
124
|
+
manual_version: fields[1].first.to_s.empty? ? "1.0.0" : fields[1].first.to_s,
|
|
125
|
+
tools: tools
|
|
126
|
+
)
|
|
127
|
+
success(template, manual)
|
|
128
|
+
rescue StandardError => error
|
|
129
|
+
client.logger.warn("Unable to register gRPC manual #{template.name.inspect}: #{error.message}")
|
|
130
|
+
failure(template, error)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def call_tool(_client, tool_name, tool_args, template)
|
|
134
|
+
assert_grpc_template!(template)
|
|
135
|
+
response = rpc_client(template).unary(
|
|
136
|
+
route(template, template.method_name || "CallTool"),
|
|
137
|
+
tool_call_request(tool_name, tool_args),
|
|
138
|
+
timeout: template.timeout,
|
|
139
|
+
metadata: grpc_metadata(template)
|
|
140
|
+
)
|
|
141
|
+
decode_tool_response(response)
|
|
142
|
+
rescue Error
|
|
143
|
+
raise
|
|
144
|
+
rescue StandardError => error
|
|
145
|
+
raise ToolCallError.new("gRPC tool #{tool_name.inspect} failed: #{error.message}", tool_name: tool_name)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def call_tool_streaming(_client, tool_name, tool_args, template)
|
|
149
|
+
return enum_for(__method__, _client, tool_name, tool_args, template) unless block_given?
|
|
150
|
+
|
|
151
|
+
assert_grpc_template!(template)
|
|
152
|
+
method = template.method_name || "CallToolStream"
|
|
153
|
+
rpc_client(template).server_stream(
|
|
154
|
+
route(template, method),
|
|
155
|
+
tool_call_request(tool_name, tool_args),
|
|
156
|
+
timeout: template.timeout,
|
|
157
|
+
metadata: grpc_metadata(template)
|
|
158
|
+
).each { |response| yield decode_tool_response(response) }
|
|
159
|
+
rescue Error
|
|
160
|
+
raise
|
|
161
|
+
rescue StandardError => error
|
|
162
|
+
raise ToolCallError.new("streaming gRPC tool #{tool_name.inspect} failed: #{error.message}", tool_name: tool_name)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def assert_grpc_template!(template)
|
|
168
|
+
return if template.is_a?(GrpcCallTemplate)
|
|
169
|
+
|
|
170
|
+
raise ValidationError, "gRPC protocol requires a GrpcCallTemplate"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def rpc_client(template)
|
|
174
|
+
@rpc_client_factory.call(template)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def route(template, method)
|
|
178
|
+
"/#{template.service_name}/#{method}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def tool_call_request(tool_name, tool_args)
|
|
182
|
+
ProtobufWire.string_field(1, tool_name) +
|
|
183
|
+
ProtobufWire.string_field(2, JSON.generate(Utils.stringify_keys(tool_args || {})))
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def decode_tool_response(bytes)
|
|
187
|
+
json = ProtobufWire.fields(bytes)[1].first.to_s
|
|
188
|
+
json.empty? ? nil : decode_json_or_text(json.force_encoding(Encoding::UTF_8))
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def grpc_metadata(template)
|
|
192
|
+
values = Utils.stringify_keys(template.metadata || {})
|
|
193
|
+
values["target"] = template.target if template.target
|
|
194
|
+
case template.auth
|
|
195
|
+
when ApiKeyAuth
|
|
196
|
+
values[template.auth.var_name.downcase] = template.auth.api_key
|
|
197
|
+
when BasicAuth
|
|
198
|
+
values["authorization"] = "Basic #{Base64.strict_encode64("#{template.auth.username}:#{template.auth.password}")}"
|
|
199
|
+
when OAuth2Auth
|
|
200
|
+
values["authorization"] = "Bearer #{oauth_token(template.auth)}"
|
|
201
|
+
end
|
|
202
|
+
values
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
GrpcCommunicationProtocol = GRPCProtocol
|
|
206
|
+
GRPCCommunicationProtocol = GRPCProtocol
|
|
207
|
+
end
|