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.
Files changed (67) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +11 -0
  3. data/LICENSE +22 -0
  4. data/Makefile +226 -0
  5. data/README.md +331 -0
  6. data/examples/basic.rb +33 -0
  7. data/examples/cli.rb +32 -0
  8. data/examples/generated/__init__.py +1 -0
  9. data/examples/generated/utcp_pb2.py +46 -0
  10. data/examples/generated/utcp_pb2_grpc.py +183 -0
  11. data/examples/graphql.rb +15 -0
  12. data/examples/grpc.rb +42 -0
  13. data/examples/grpc_python.py +52 -0
  14. data/examples/http.rb +17 -0
  15. data/examples/mcp.rb +28 -0
  16. data/examples/servers/graphql_server.rb +39 -0
  17. data/examples/servers/grpc_server.py +97 -0
  18. data/examples/servers/grpc_server.rb +62 -0
  19. data/examples/servers/http_helpers.rb +34 -0
  20. data/examples/servers/http_server.rb +28 -0
  21. data/examples/servers/mcp_stdio_server.rb +43 -0
  22. data/examples/servers/requirements-grpc.txt +2 -0
  23. data/examples/servers/sse_server.rb +36 -0
  24. data/examples/servers/streamable_http_server.rb +39 -0
  25. data/examples/servers/tcp_server.rb +58 -0
  26. data/examples/servers/udp_server.rb +33 -0
  27. data/examples/servers/webrtc_server.rb +78 -0
  28. data/examples/servers/websocket_server.rb +92 -0
  29. data/examples/sse.rb +16 -0
  30. data/examples/streamable_http.rb +17 -0
  31. data/examples/tcp.rb +20 -0
  32. data/examples/text.rb +23 -0
  33. data/examples/udp.rb +18 -0
  34. data/examples/webrtc.rb +19 -0
  35. data/examples/websocket.rb +17 -0
  36. data/lib/ruby-utcp.rb +4 -0
  37. data/lib/utcp/client.rb +217 -0
  38. data/lib/utcp/config.rb +79 -0
  39. data/lib/utcp/errors.rb +48 -0
  40. data/lib/utcp/migration.rb +88 -0
  41. data/lib/utcp/models.rb +794 -0
  42. data/lib/utcp/openapi_converter.rb +179 -0
  43. data/lib/utcp/protocols/base.rb +97 -0
  44. data/lib/utcp/protocols/cli.rb +186 -0
  45. data/lib/utcp/protocols/file.rb +52 -0
  46. data/lib/utcp/protocols/graphql.rb +277 -0
  47. data/lib/utcp/protocols/grpc.rb +207 -0
  48. data/lib/utcp/protocols/http.rb +340 -0
  49. data/lib/utcp/protocols/http_stream_support.rb +122 -0
  50. data/lib/utcp/protocols/mcp.rb +339 -0
  51. data/lib/utcp/protocols/socket_support.rb +51 -0
  52. data/lib/utcp/protocols/sse.rb +107 -0
  53. data/lib/utcp/protocols/streamable_http.rb +78 -0
  54. data/lib/utcp/protocols/tcp.rb +143 -0
  55. data/lib/utcp/protocols/text.rb +44 -0
  56. data/lib/utcp/protocols/udp.rb +61 -0
  57. data/lib/utcp/protocols/webrtc.rb +217 -0
  58. data/lib/utcp/protocols/websocket.rb +350 -0
  59. data/lib/utcp/registry.rb +67 -0
  60. data/lib/utcp/repository.rb +137 -0
  61. data/lib/utcp/serializer.rb +71 -0
  62. data/lib/utcp/utils.rb +118 -0
  63. data/lib/utcp/variables.rb +170 -0
  64. data/lib/utcp/version.rb +6 -0
  65. data/lib/utcp.rb +70 -0
  66. data/proto/utcp.proto +31 -0
  67. metadata +148 -0
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module UTCP
6
+ class OpenAPIConverter
7
+ HTTP_METHODS = %w[get post put delete patch head options].freeze
8
+
9
+ def initialize(spec, spec_url: nil, call_template_name: nil, auth_tools: nil, base_url: nil)
10
+ @spec = Utils.stringify_keys(Utils.hash!(spec, "OpenAPI document"))
11
+ @spec_url = spec_url
12
+ @call_template_name = call_template_name
13
+ @auth_tools = auth_tools
14
+ @base_url = base_url
15
+ end
16
+
17
+ def convert
18
+ paths = @spec["paths"]
19
+ raise ValidationError, "OpenAPI document must contain a paths object" unless paths.is_a?(Hash)
20
+
21
+ tools = []
22
+ paths.each do |path, path_item|
23
+ next unless path_item.is_a?(Hash)
24
+
25
+ HTTP_METHODS.each do |method|
26
+ operation = path_item[method]
27
+ next unless operation.is_a?(Hash)
28
+
29
+ tools << convert_operation(path, method, path_item, operation)
30
+ end
31
+ end
32
+
33
+ Manual.new(
34
+ utcp_version: VERSION,
35
+ manual_version: "1.0.0",
36
+ info: @spec["info"] || {},
37
+ tools: tools
38
+ )
39
+ end
40
+
41
+ private
42
+
43
+ def convert_operation(path, method, path_item, operation)
44
+ parameters = Array(path_item["parameters"]) + Array(operation["parameters"])
45
+ properties = {}
46
+ required = []
47
+ header_fields = []
48
+ body_field = "body"
49
+
50
+ parameters.each do |parameter|
51
+ next unless parameter.is_a?(Hash)
52
+
53
+ parameter = Utils.stringify_keys(parameter)
54
+ name = parameter["name"].to_s
55
+ next if name.empty?
56
+
57
+ if parameter["in"] == "body"
58
+ properties[body_field] = resolve_schema(parameter["schema"] || {})
59
+ required << body_field if parameter["required"]
60
+ else
61
+ properties[name] = resolve_schema(parameter["schema"] || parameter_schema(parameter))
62
+ required << name if parameter["required"] || parameter["in"] == "path"
63
+ header_fields << name if parameter["in"] == "header"
64
+ end
65
+ end
66
+
67
+ request_body = operation["requestBody"]
68
+ if request_body.is_a?(Hash)
69
+ content_type, media = Array(request_body["content"]).first
70
+ if media.is_a?(Hash)
71
+ properties[body_field] = resolve_schema(media["schema"] || {})
72
+ required << body_field if request_body["required"]
73
+ end
74
+ else
75
+ content_type = nil
76
+ end
77
+
78
+ tool_name = operation["operationId"].to_s
79
+ tool_name = generated_name(method, path) if tool_name.empty?
80
+ description = operation["description"] || operation["summary"] || "#{method.upcase} #{path}"
81
+ input_schema = { "type" => "object", "properties" => properties }
82
+ input_schema["required"] = required.uniq unless required.empty?
83
+
84
+ Tool.new(
85
+ name: tool_name,
86
+ description: description.to_s,
87
+ inputs: input_schema,
88
+ outputs: response_schema(operation),
89
+ tags: Array(operation["tags"]),
90
+ tool_call_template: HttpCallTemplate.new(
91
+ name: tool_name,
92
+ url: join_url(resolve_base_url, path),
93
+ http_method: method.upcase,
94
+ content_type: content_type || Array(operation["consumes"]).first || "application/json",
95
+ body_field: body_field,
96
+ header_fields: header_fields,
97
+ auth: operation_requires_auth?(operation) ? @auth_tools : nil
98
+ )
99
+ )
100
+ end
101
+
102
+ def parameter_schema(parameter)
103
+ parameter.each_with_object({}) do |(key, value), schema|
104
+ schema[key] = value if %w[type format enum default items minimum maximum pattern minLength maxLength].include?(key)
105
+ end
106
+ end
107
+
108
+ def resolve_schema(schema, seen = [])
109
+ data = Utils.stringify_keys(schema || {})
110
+ reference = data["$ref"]
111
+ return data unless reference&.start_with?("#/")
112
+ return {} if seen.include?(reference)
113
+
114
+ resolved = reference.sub(%r{\A#/}, "").split("/").reduce(@spec) do |node, component|
115
+ break nil unless node.is_a?(Hash)
116
+
117
+ node[component.gsub("~1", "/").gsub("~0", "~")]
118
+ end
119
+ resolved ? resolve_schema(resolved, seen + [reference]) : data
120
+ end
121
+
122
+ def response_schema(operation)
123
+ responses = operation["responses"]
124
+ return {} unless responses.is_a?(Hash)
125
+
126
+ _status, response = responses.find { |status, _value| status.to_s.match?(/\A2\d\d\z/) } || responses.first
127
+ return {} unless response.is_a?(Hash)
128
+
129
+ if response["content"].is_a?(Hash)
130
+ media = response["content"].values.first
131
+ resolve_schema(media.is_a?(Hash) ? media["schema"] : {})
132
+ else
133
+ resolve_schema(response["schema"] || {})
134
+ end
135
+ end
136
+
137
+ def operation_requires_auth?(operation)
138
+ security = operation.key?("security") ? operation["security"] : @spec["security"]
139
+ security.is_a?(Array) && !security.empty?
140
+ end
141
+
142
+ def resolve_base_url
143
+ return @base_url if @base_url && !@base_url.empty?
144
+
145
+ server = Array(@spec["servers"]).first
146
+ return substitute_server_variables(server) if server.is_a?(Hash) && server["url"]
147
+
148
+ if @spec["host"]
149
+ scheme = Array(@spec["schemes"]).first || "https"
150
+ return "#{scheme}://#{@spec['host']}#{@spec['basePath']}"
151
+ end
152
+
153
+ if @spec_url&.match?(/\Ahttps?:/)
154
+ uri = URI.parse(@spec_url)
155
+ return "#{uri.scheme}://#{uri.host}#{uri.port && ![80, 443].include?(uri.port) ? ":#{uri.port}" : ""}"
156
+ end
157
+
158
+ raise ValidationError, "OpenAPI document does not define a server URL; pass base_url"
159
+ end
160
+
161
+ def substitute_server_variables(server)
162
+ variables = Utils.stringify_keys(server["variables"] || {})
163
+ server["url"].gsub(/\{([^}]+)\}/) do
164
+ variable = variables[Regexp.last_match(1)]
165
+ variable.is_a?(Hash) ? variable["default"].to_s : Regexp.last_match(0)
166
+ end
167
+ end
168
+
169
+ def join_url(base, path)
170
+ "#{base.to_s.sub(%r{/+\z}, '')}/#{path.to_s.sub(%r{\A/+}, '')}"
171
+ end
172
+
173
+ def generated_name(method, path)
174
+ "#{method}_#{path}".gsub(/[^A-Za-z0-9_]+/, "_").gsub(/\A_+|_+\z/, "")
175
+ end
176
+ end
177
+ OpenApiConverter = OpenAPIConverter
178
+ end
179
+
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module UTCP
6
+ class CommunicationProtocol
7
+ def register_manual(_client, _manual_call_template)
8
+ raise NotImplementedError
9
+ end
10
+
11
+ def deregister_manual(_client, _manual_call_template)
12
+ nil
13
+ end
14
+
15
+ def call_tool(_client, _tool_name, _tool_args, _tool_call_template)
16
+ raise NotImplementedError
17
+ end
18
+
19
+ def call_tool_streaming(client, tool_name, tool_args, tool_call_template)
20
+ return enum_for(__method__, client, tool_name, tool_args, tool_call_template) unless block_given?
21
+
22
+ yield call_tool(client, tool_name, tool_args, tool_call_template)
23
+ end
24
+
25
+ private
26
+
27
+ def manual_from_payload(template, payload, source: "protocol response")
28
+ data = if payload.is_a?(String)
29
+ Utils.parse_document(payload, source: source)
30
+ else
31
+ Utils.stringify_keys(payload)
32
+ end
33
+ data = { "tools" => data } if data.is_a?(Array)
34
+ data = Migration.manual_v0_1_to_v1_1(data) if Migration.v0_1_manual?(data)
35
+ data = Utils.hash!(data, source)
36
+ data["utcp_version"] ||= VERSION
37
+ data["manual_version"] ||= "1.0.0"
38
+ data["tools"] = Utils.array!(data.fetch("tools", []), "#{source}.tools").map do |raw_tool|
39
+ tool = Utils.stringify_keys(Utils.hash!(raw_tool, "#{source}.tool"))
40
+ tool["tool_call_template"] ||= tool.delete("tool_provider") || template.to_h
41
+ tool
42
+ end
43
+ Manual.from_h(data)
44
+ end
45
+
46
+ def decode_json_or_text(value)
47
+ return value unless value.is_a?(String)
48
+
49
+ stripped = value.strip
50
+ return value if stripped.empty? || !stripped.start_with?("{", "[", '"') && stripped !~ /\A(?:true|false|null|-?\d)/
51
+
52
+ JSON.parse(stripped)
53
+ rescue JSON::ParserError
54
+ value
55
+ end
56
+
57
+ def substitute_message_template(value, arguments)
58
+ args = Utils.stringify_keys(arguments || {})
59
+ case value
60
+ when Hash
61
+ value.each_with_object({}) do |(key, item), result|
62
+ result[key.to_s] = substitute_message_template(item, args)
63
+ end
64
+ when Array
65
+ value.map { |item| substitute_message_template(item, args) }
66
+ when String
67
+ value.gsub(/UTCP_ARG_([A-Za-z0-9_]+)_UTCP_ARG/) do
68
+ name = Regexp.last_match(1)
69
+ raise ToolCallError, "Missing required transport argument: #{name}" unless args.key?(name)
70
+
71
+ replacement = args[name]
72
+ replacement.is_a?(String) ? replacement : JSON.generate(replacement)
73
+ end
74
+ else
75
+ value
76
+ end
77
+ end
78
+
79
+ def success(template, manual)
80
+ RegisterManualResult.new(
81
+ manual_call_template: template,
82
+ manual: manual,
83
+ success: true,
84
+ errors: []
85
+ )
86
+ end
87
+
88
+ def failure(template, error)
89
+ RegisterManualResult.new(
90
+ manual_call_template: template,
91
+ manual: Manual.new(tools: [], manual_version: "0.0.0"),
92
+ success: false,
93
+ errors: [error.is_a?(Exception) ? error.message : error.to_s]
94
+ )
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "timeout"
6
+
7
+ module UTCP
8
+ class CLIProtocol < CommunicationProtocol
9
+ PLACEHOLDER = /UTCP_ARG_([A-Za-z0-9_]+)_UTCP_END/.freeze
10
+ DEFAULT_ENVIRONMENT = %w[
11
+ PATH HOME LANG LANGUAGE LC_ALL LC_CTYPE TMPDIR TEMP TMP SHELL USER LOGNAME
12
+ ].freeze
13
+
14
+ def register_manual(client, template)
15
+ assert_template!(template)
16
+ output = execute(client, template, {})
17
+ data = Utils.parse_document(output, source: "CLI output")
18
+ data = { "utcp_version" => VERSION, "manual_version" => "1.0.0", "tools" => data } if data.is_a?(Array)
19
+ data = Migration.manual_v0_1_to_v1_1(data) if Migration.v0_1_manual?(data)
20
+ success(template, Manual.from_h(data))
21
+ rescue StandardError => error
22
+ client.logger.warn("Unable to register CLI manual #{template.name.inspect}: #{error.message}")
23
+ failure(template, error)
24
+ end
25
+
26
+ def call_tool(client, tool_name, tool_args, template)
27
+ assert_template!(template)
28
+ output = execute(client, template, tool_args || {})
29
+ stripped = output.strip
30
+ if stripped.start_with?("{", "[")
31
+ JSON.parse(stripped)
32
+ else
33
+ stripped
34
+ end
35
+ rescue JSON::ParserError
36
+ output.strip
37
+ rescue Error
38
+ raise
39
+ rescue StandardError => error
40
+ raise ToolCallError.new("CLI tool #{tool_name.inspect} failed: #{error.message}", tool_name: tool_name)
41
+ end
42
+
43
+ private
44
+
45
+ def assert_template!(template)
46
+ return if template.is_a?(CliCallTemplate)
47
+
48
+ raise ValidationError, "CLI protocol requires a CliCallTemplate"
49
+ end
50
+
51
+ def execute(client, template, arguments)
52
+ env = environment_for(template)
53
+ commands = template.commands.each_with_index.map do |step, index|
54
+ command, argument_env = interpolate(step.command, arguments)
55
+ env.merge!(argument_env)
56
+ [command, include_output?(step, index, template.commands.length)]
57
+ end
58
+ script = build_script(commands)
59
+ working_dir = template.working_dir ? File.expand_path(template.working_dir, client.root_dir) : client.root_dir
60
+ raise ToolCallError, "CLI working directory does not exist: #{working_dir}" unless File.directory?(working_dir)
61
+
62
+ run_script(script, env, working_dir, template.timeout)
63
+ end
64
+
65
+ def environment_for(template)
66
+ names = template.inherit_env_vars.nil? ? DEFAULT_ENVIRONMENT : template.inherit_env_vars
67
+ environment = names.each_with_object({}) do |name, selected|
68
+ selected[name] = ENV[name] if ENV.key?(name)
69
+ end
70
+ template.env_vars.each { |name, value| environment[name] = value.to_s }
71
+ environment
72
+ end
73
+
74
+ def interpolate(command, arguments)
75
+ args = Utils.stringify_keys(arguments)
76
+ env = {}
77
+ result = +""
78
+ quote = nil
79
+ escaped = false
80
+ index = 0
81
+ variable_numbers = {}
82
+
83
+ while index < command.length
84
+ match = PLACEHOLDER.match(command, index)
85
+ if match && match.begin(0) == index
86
+ name = match[1]
87
+ raise ToolCallError, "Missing required CLI argument: #{name}" unless args.key?(name)
88
+
89
+ number = variable_numbers.fetch(name) { variable_numbers[name] = variable_numbers.length }
90
+ variable = "UTCP_TOOL_ARG_#{number}"
91
+ value = args[name]
92
+ env[variable] = value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value.to_s
93
+ result << case quote
94
+ when "'" then %Q('"${#{variable}}"')
95
+ when '"' then "${#{variable}}"
96
+ else %Q("${#{variable}}")
97
+ end
98
+ index = match.end(0)
99
+ next
100
+ end
101
+
102
+ character = command[index]
103
+ result << character
104
+ if escaped
105
+ escaped = false
106
+ elsif character == "\\" && quote != "'"
107
+ escaped = true
108
+ elsif character == "'" && quote != '"'
109
+ quote = quote == "'" ? nil : "'"
110
+ elsif character == '"' && quote != "'"
111
+ quote = quote == '"' ? nil : '"'
112
+ end
113
+ index += 1
114
+ end
115
+ [result, env]
116
+ end
117
+
118
+ def include_output?(step, index, length)
119
+ step.append_to_final_output.nil? ? index == length - 1 : step.append_to_final_output
120
+ end
121
+
122
+ def build_script(commands)
123
+ lines = []
124
+ commands.each_with_index do |(command, append), index|
125
+ lines << "__utcp_output_#{index}=$( { #{command}; } 2>&1 )"
126
+ lines << "__utcp_status_#{index}=$?"
127
+ lines << "CMD_#{index}_OUTPUT=$__utcp_output_#{index}"
128
+ lines << "export CMD_#{index}_OUTPUT"
129
+ lines << "printf '%s\\n' \"$__utcp_output_#{index}\"" if append
130
+ lines << "if [ \"$__utcp_status_#{index}\" -ne 0 ]; then printf '%s\\n' \"$__utcp_output_#{index}\" >&2; exit \"$__utcp_status_#{index}\"; fi"
131
+ end
132
+ lines.join("\n")
133
+ end
134
+
135
+ def run_script(script, environment, working_dir, timeout_seconds)
136
+ stdout_text = nil
137
+ stderr_text = nil
138
+ status = nil
139
+ wait_thread = nil
140
+
141
+ Open3.popen3(
142
+ environment,
143
+ "/bin/sh", "-c", script,
144
+ unsetenv_others: true,
145
+ chdir: working_dir,
146
+ pgroup: true
147
+ ) do |stdin, stdout, stderr, thread|
148
+ wait_thread = thread
149
+ stdin.close
150
+ stdout_reader = Thread.new { stdout.read }
151
+ stderr_reader = Thread.new { stderr.read }
152
+ begin
153
+ Timeout.timeout(timeout_seconds) do
154
+ stdout_text = stdout_reader.value
155
+ stderr_text = stderr_reader.value
156
+ status = thread.value
157
+ end
158
+ rescue Timeout::Error
159
+ terminate_process_group(thread.pid)
160
+ stdout_reader.kill
161
+ stderr_reader.kill
162
+ raise TimeoutError, "CLI command timed out after #{timeout_seconds} seconds"
163
+ end
164
+ end
165
+
166
+ unless status&.success?
167
+ detail = stderr_text.to_s.strip
168
+ detail = stdout_text.to_s.strip if detail.empty?
169
+ raise ToolCallError.new("CLI command exited with status #{status&.exitstatus}: #{detail}", status: status&.exitstatus)
170
+ end
171
+ stdout_text.to_s
172
+ ensure
173
+ terminate_process_group(wait_thread.pid) if wait_thread&.alive?
174
+ end
175
+
176
+ def terminate_process_group(pid)
177
+ Process.kill("TERM", -pid)
178
+ sleep(0.05)
179
+ Process.kill("KILL", -pid)
180
+ rescue Errno::ESRCH, Errno::EPERM
181
+ nil
182
+ end
183
+ end
184
+ CliCommunicationProtocol = CLIProtocol
185
+ end
186
+
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module UTCP
6
+ class FileProtocol < CommunicationProtocol
7
+ def register_manual(client, template)
8
+ assert_template!(template)
9
+ path = resolve_path(client, template.file_path)
10
+ data = Utils.load_document(path)
11
+ manual = if openapi?(data)
12
+ OpenAPIConverter.new(
13
+ data,
14
+ spec_url: Pathname.new(path).expand_path.to_s,
15
+ call_template_name: template.name,
16
+ auth_tools: template.auth_tools
17
+ ).convert
18
+ else
19
+ Manual.from_h(data)
20
+ end
21
+ success(template, manual)
22
+ rescue StandardError => error
23
+ client.logger.warn("Unable to register file manual #{template.name.inspect}: #{error.message}")
24
+ failure(template, error)
25
+ end
26
+
27
+ def call_tool(client, _tool_name, _tool_args, template)
28
+ assert_template!(template)
29
+ File.read(resolve_path(client, template.file_path), mode: "r:bom|utf-8")
30
+ rescue Errno::ENOENT, Errno::EACCES => error
31
+ raise ToolCallError, "Unable to read tool file: #{error.message}"
32
+ end
33
+
34
+ private
35
+
36
+ def assert_template!(template)
37
+ return if template.is_a?(FileCallTemplate)
38
+
39
+ raise ValidationError, "file protocol requires a FileCallTemplate"
40
+ end
41
+
42
+ def resolve_path(client, path)
43
+ File.expand_path(path, client.root_dir)
44
+ end
45
+
46
+ def openapi?(data)
47
+ data.is_a?(Hash) && (data.key?("openapi") || data.key?("swagger") || data.key?("paths"))
48
+ end
49
+ end
50
+ FileCommunicationProtocol = FileProtocol
51
+ end
52
+