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,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module UTCP
6
+ class Client
7
+ attr_reader :config, :root_dir, :variable_substitutor, :logger, :registration_results
8
+
9
+ def self.create(root_dir: nil, config: nil, logger: nil)
10
+ client = new(root_dir: root_dir, config: config, logger: logger)
11
+ client.register_configured_manuals
12
+ client
13
+ end
14
+
15
+ def initialize(root_dir: nil, config: nil, logger: nil)
16
+ @root_dir = File.expand_path(root_dir || Dir.pwd)
17
+ @logger = logger || Logger.new($stderr, level: Logger::WARN)
18
+ @config = ClientConfig.from(config, root_dir: @root_dir)
19
+ @variable_substitutor = VariableSubstitutor.new
20
+ @registration_results = []
21
+ end
22
+
23
+ def register_configured_manuals
24
+ @registration_results = register_manuals(config.manual_call_templates)
25
+ self
26
+ end
27
+
28
+ def register_manual(manual_call_template)
29
+ template = copy_template(CallTemplate.from_h(manual_call_template))
30
+ template.name = sanitize_name(template.name)
31
+ if config.tool_repository.get_manual(template.name)
32
+ raise ManualAlreadyRegisteredError,
33
+ "Manual #{template.name.inspect} is already registered; deregister it or use another name"
34
+ end
35
+
36
+ template = substitute_template(template, template.name)
37
+ protocol = fetch_protocol(template.call_template_type)
38
+ result = protocol.register_manual(self, template)
39
+ return result unless result.success?
40
+
41
+ allowed = template.allowed_protocols
42
+ filtered = result.manual.tools.each_with_object([]) do |tool, tools|
43
+ type = tool.tool_call_template&.call_template_type || template.call_template_type
44
+ if allowed.include?(type)
45
+ tool.name = "#{template.name}.#{tool.name}" unless tool.name.start_with?("#{template.name}.")
46
+ tools << tool
47
+ else
48
+ logger.warn(
49
+ "Tool #{tool.name.inspect} uses protocol #{type.inspect}, which is not allowed " \
50
+ "by manual #{template.name.inspect}; allowed protocols: #{allowed.inspect}"
51
+ )
52
+ end
53
+ end
54
+ result.manual.tools = filtered
55
+ result.manual_call_template = template
56
+ config.tool_repository.save_manual(template, result.manual)
57
+ result
58
+ end
59
+
60
+ def register_manuals(manual_call_templates)
61
+ Array(manual_call_templates).map do |template|
62
+ register_manual(template)
63
+ rescue VariableNotFoundError
64
+ raise
65
+ rescue StandardError => error
66
+ parsed = CallTemplate.from_h(template)
67
+ logger.warn("Unable to register manual #{parsed.name.inspect}: #{error.message}")
68
+ RegisterManualResult.new(
69
+ manual_call_template: parsed,
70
+ manual: Manual.new(tools: [], manual_version: "0.0.0"),
71
+ success: false,
72
+ errors: [error.message]
73
+ )
74
+ end
75
+ end
76
+
77
+ def deregister_manual(manual_name)
78
+ template = config.tool_repository.get_manual_call_template(manual_name)
79
+ return false unless template
80
+
81
+ fetch_protocol(template.call_template_type).deregister_manual(self, template)
82
+ config.tool_repository.remove_manual(manual_name)
83
+ end
84
+
85
+ def call_tool(tool_name, tool_args = {})
86
+ tool = config.tool_repository.get_tool(tool_name)
87
+ raise ToolNotFoundError, "Tool not found: #{tool_name}" unless tool
88
+
89
+ manual_name = tool_name.to_s.split(".", 2).first
90
+ template = substitute_template(tool.tool_call_template, manual_name)
91
+ enforce_allowed_protocol!(manual_name, tool_name, template.call_template_type)
92
+ result = fetch_protocol(template.call_template_type).call_tool(self, tool_name, tool_args, template)
93
+ apply_post_processing(result, tool, template)
94
+ rescue Error
95
+ raise
96
+ rescue StandardError => error
97
+ raise ToolCallError.new("Tool #{tool_name.inspect} failed: #{error.message}", tool_name: tool_name)
98
+ end
99
+
100
+ def call_tool_streaming(tool_name, tool_args = {})
101
+ return enum_for(__method__, tool_name, tool_args) unless block_given?
102
+
103
+ tool = config.tool_repository.get_tool(tool_name)
104
+ raise ToolNotFoundError, "Tool not found: #{tool_name}" unless tool
105
+
106
+ manual_name = tool_name.to_s.split(".", 2).first
107
+ template = substitute_template(tool.tool_call_template, manual_name)
108
+ enforce_allowed_protocol!(manual_name, tool_name, template.call_template_type)
109
+ protocol = fetch_protocol(template.call_template_type)
110
+ protocol.call_tool_streaming(self, tool_name, tool_args, template) do |item|
111
+ yield apply_post_processing(item, tool, template)
112
+ end
113
+ end
114
+
115
+ def search_tools(query, limit: 10, any_of_tags_required: nil)
116
+ config.tool_search_strategy.search_tools(
117
+ tool_repository: config.tool_repository,
118
+ query: query,
119
+ limit: limit,
120
+ any_of_tags_required: any_of_tags_required
121
+ )
122
+ end
123
+
124
+ def list_tools
125
+ config.tool_repository.get_tools
126
+ end
127
+
128
+ def get_required_variables_for_manual_and_tools(manual_call_template)
129
+ template = copy_template(CallTemplate.from_h(manual_call_template))
130
+ template.name = sanitize_name(template.name)
131
+ required = variable_substitutor.find_required_variables(template.to_h, template.name)
132
+ return required unless required.empty?
133
+
134
+ substituted = substitute_template(template, template.name)
135
+ result = fetch_protocol(substituted.call_template_type).register_manual(self, substituted)
136
+ return [] unless result.success?
137
+
138
+ result.manual.tools.flat_map do |tool|
139
+ variable_substitutor.find_required_variables(tool.tool_call_template.to_h, template.name)
140
+ end.uniq
141
+ end
142
+
143
+ def get_required_variables_for_registered_tool(tool_name)
144
+ tool = config.tool_repository.get_tool(tool_name)
145
+ raise ToolNotFoundError, "Tool not found: #{tool_name}" unless tool
146
+
147
+ manual_name = tool_name.to_s.split(".", 2).first
148
+ variable_substitutor.find_required_variables(tool.tool_call_template.to_h, manual_name)
149
+ end
150
+
151
+ def manual(name)
152
+ config.tool_repository.get_manual(name)
153
+ end
154
+
155
+ def manuals
156
+ config.tool_repository.get_manuals
157
+ end
158
+
159
+ def close
160
+ config.tool_repository.get_manual_call_templates.each do |template|
161
+ deregister_manual(template.name)
162
+ rescue StandardError => error
163
+ logger.warn("Unable to deregister manual #{template.name.inspect}: #{error.message}")
164
+ end
165
+ nil
166
+ end
167
+
168
+ private
169
+
170
+ def fetch_protocol(type)
171
+ UTCP.protocol(type) || raise(
172
+ ProtocolNotFoundError,
173
+ "No communication protocol registered for #{type.inspect}; available: #{UTCP.protocol_types.join(', ')}"
174
+ )
175
+ end
176
+
177
+ def sanitize_name(name)
178
+ sanitized = name.to_s.gsub(/[^[:alnum:]_]/, "_")
179
+ raise ValidationError, "manual name cannot be empty" if sanitized.empty?
180
+
181
+ sanitized
182
+ end
183
+
184
+ def substitute_template(template, namespace)
185
+ substituted = variable_substitutor.substitute(template.to_h, config, namespace)
186
+ CallTemplate.from_h(substituted)
187
+ end
188
+
189
+ def copy_template(template)
190
+ CallTemplate.from_h(template.to_h)
191
+ end
192
+
193
+ def enforce_allowed_protocol!(manual_name, tool_name, type)
194
+ manual_template = config.tool_repository.get_manual_call_template(manual_name)
195
+ return unless manual_template
196
+ return if manual_template.allowed_protocols.include?(type)
197
+
198
+ raise ProtocolNotAllowedError,
199
+ "Tool #{tool_name.inspect} uses protocol #{type.inspect}, which is not allowed by " \
200
+ "manual #{manual_name.inspect}; allowed protocols: #{manual_template.allowed_protocols.inspect}"
201
+ end
202
+
203
+ def apply_post_processing(result, tool, template)
204
+ config.post_processing.reduce(result) do |value, processor|
205
+ if processor.respond_to?(:post_process)
206
+ processor.post_process(self, tool, template, value)
207
+ elsif processor.respond_to?(:call)
208
+ processor.call(value)
209
+ else
210
+ raise ValidationError, "post processor must respond to call or post_process"
211
+ end
212
+ end
213
+ end
214
+ end
215
+ UtcpClient = Client
216
+ end
217
+
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UTCP
4
+ class ClientConfig
5
+ include ModelSerialization
6
+ attr_accessor :variables, :load_variables_from, :tool_repository,
7
+ :tool_search_strategy, :post_processing, :manual_call_templates
8
+
9
+ def self.from(value = nil, root_dir: Dir.pwd)
10
+ return new(root_dir: root_dir) if value.nil?
11
+ return value if value.is_a?(self)
12
+
13
+ data = if value.respond_to?(:to_path) || value.is_a?(String)
14
+ Utils.load_document(File.expand_path(value.to_s, root_dir))
15
+ else
16
+ value
17
+ end
18
+ new(root_dir: root_dir, **Utils.symbolize_keys(Utils.stringify_keys(Utils.hash!(data, "client config"))))
19
+ end
20
+
21
+ def self.from_h(value, root_dir: Dir.pwd)
22
+ from(value, root_dir: root_dir)
23
+ end
24
+
25
+ def initialize(variables: nil, load_variables_from: nil, tool_repository: nil,
26
+ tool_search_strategy: nil, post_processing: nil,
27
+ manual_call_templates: nil, root_dir: Dir.pwd, **_extra)
28
+ @variables = Utils.stringify_keys(variables || {})
29
+ @load_variables_from = Array(load_variables_from).map do |loader|
30
+ VariableLoader.from_h(loader, root_dir: root_dir)
31
+ end
32
+ @tool_repository = build_repository(tool_repository)
33
+ @tool_search_strategy = build_search_strategy(tool_search_strategy)
34
+ @post_processing = Array(post_processing)
35
+ @manual_call_templates = Array(manual_call_templates).map { |template| CallTemplate.from_h(template) }
36
+ end
37
+
38
+ def to_h
39
+ {
40
+ "variables" => Utils.deep_copy(variables),
41
+ "load_variables_from" => load_variables_from.map { |loader| loader.respond_to?(:to_h) ? loader.to_h : loader },
42
+ "tool_repository" => tool_repository.respond_to?(:to_h) ? tool_repository.to_h : tool_repository,
43
+ "tool_search_strategy" => tool_search_strategy.respond_to?(:to_h) ? tool_search_strategy.to_h : tool_search_strategy,
44
+ "post_processing" => post_processing.map { |processor| processor.respond_to?(:to_h) ? processor.to_h : processor },
45
+ "manual_call_templates" => manual_call_templates.map(&:to_h)
46
+ }
47
+ end
48
+
49
+ private
50
+
51
+ def build_repository(value)
52
+ return value if value && value.respond_to?(:save_manual)
53
+
54
+ data = Utils.stringify_keys(value || { "tool_repository_type" => "in_memory" })
55
+ case data["tool_repository_type"]
56
+ when nil, "in_memory"
57
+ InMemoryToolRepository.new
58
+ else
59
+ raise ValidationError, "Unsupported tool_repository_type: #{data['tool_repository_type']}"
60
+ end
61
+ end
62
+
63
+ def build_search_strategy(value)
64
+ return value if value && value.respond_to?(:search_tools)
65
+
66
+ data = Utils.stringify_keys(value || { "tool_search_strategy_type" => "tag_and_description_word_match" })
67
+ case data["tool_search_strategy_type"]
68
+ when nil, "tag_and_description_word_match"
69
+ TagSearchStrategy.new(
70
+ description_weight: data.fetch("description_weight", 1),
71
+ tag_weight: data.fetch("tag_weight", 3)
72
+ )
73
+ else
74
+ raise ValidationError, "Unsupported tool_search_strategy_type: #{data['tool_search_strategy_type']}"
75
+ end
76
+ end
77
+ end
78
+ UtcpClientConfig = ClientConfig
79
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UTCP
4
+ class Error < StandardError; end
5
+ UtcpError = Error
6
+ UTCPError = Error
7
+
8
+ class ValidationError < Error
9
+ attr_reader :path
10
+
11
+ def initialize(message, path: nil)
12
+ @path = path
13
+ super(path ? "#{path}: #{message}" : message)
14
+ end
15
+ end
16
+
17
+ class SerializerValidationError < ValidationError; end
18
+ UtcpSerializerValidationError = SerializerValidationError
19
+ class VariableNotFoundError < Error
20
+ attr_reader :variable_name
21
+
22
+ def initialize(variable_name)
23
+ @variable_name = variable_name
24
+ super("Required variable not found: #{variable_name}")
25
+ end
26
+ end
27
+ UtcpVariableNotFound = VariableNotFoundError
28
+ UtcpVariableNotFoundException = VariableNotFoundError
29
+
30
+ class ToolNotFoundError < Error; end
31
+ class ManualAlreadyRegisteredError < Error; end
32
+ class ProtocolNotFoundError < Error; end
33
+ class ProtocolNotAllowedError < Error; end
34
+ class MissingDependencyError < Error; end
35
+ class AuthenticationError < Error; end
36
+ class ToolCallError < Error
37
+ attr_reader :tool_name, :status, :response_body
38
+
39
+ def initialize(message, tool_name: nil, status: nil, response_body: nil)
40
+ @tool_name = tool_name
41
+ @status = status
42
+ @response_body = response_body
43
+ super(message)
44
+ end
45
+ end
46
+ class SecurityError < Error; end
47
+ class TimeoutError < ToolCallError; end
48
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module UTCP
6
+ module Migration
7
+ module_function
8
+
9
+ def config_v0_1_to_v1_1(config)
10
+ data = Utils.stringify_keys(Utils.deep_copy(Utils.hash!(config, "v0.1 config")))
11
+ providers = Array(data.delete("providers"))
12
+ existing = Array(data["manual_call_templates"])
13
+ data["manual_call_templates"] = existing + providers.map { |provider| migrate_provider(provider) }
14
+ data["variables"] ||= {}
15
+ data
16
+ end
17
+
18
+ def manual_v0_1_to_v1_1(manual)
19
+ data = Utils.stringify_keys(Utils.deep_copy(Utils.hash!(manual, "v0.1 manual")))
20
+ provider_info = Utils.stringify_keys(data.delete("provider_info") || {})
21
+ info = Utils.stringify_keys(data["info"] || {})
22
+ info["title"] ||= provider_info["name"] || "UTCP Manual"
23
+ info["version"] ||= provider_info["version"] || "1.0.0"
24
+ info["description"] ||= provider_info["description"] if provider_info["description"]
25
+
26
+ {
27
+ "manual_version" => "1.0.0",
28
+ "utcp_version" => VERSION,
29
+ "info" => info,
30
+ "tools" => Array(data["tools"]).map { |tool| migrate_tool(tool) }
31
+ }
32
+ end
33
+
34
+ def v0_1_manual?(value)
35
+ return false unless value.is_a?(Hash)
36
+
37
+ data = Utils.stringify_keys(value)
38
+ data.key?("provider_info") || Array(data["tools"]).any? do |tool|
39
+ tool.is_a?(Hash) && (tool.key?("provider") || tool.key?("parameters") || tool.key?(:provider))
40
+ end
41
+ end
42
+
43
+ def migrate_provider(provider)
44
+ source = Utils.stringify_keys(Utils.deep_copy(Utils.hash!(provider, "provider")))
45
+ type = source.delete("provider_type") || source.delete("type") || source["call_template_type"]
46
+ raise ValidationError, "v0.1 provider is missing provider_type" if type.nil? || type.to_s.empty?
47
+
48
+ source["call_template_type"] = type.to_s
49
+ source["http_method"] = source.delete("method") if source["method"] && !source["http_method"]
50
+ source["working_dir"] = source.delete("cwd") if source["cwd"] && !source["working_dir"]
51
+ source["working_dir"] = source.delete("working_directory") if source["working_directory"] && !source["working_dir"]
52
+
53
+ if type.to_s == "cli" && source["command"]
54
+ command = source.delete("command").to_s
55
+ arguments = Array(source.delete("args")).map { |argument| migrate_cli_argument(argument) }
56
+ source["commands"] ||= [{ "command" => ([command] + arguments).join(" "), "append_to_final_output" => true }]
57
+ end
58
+ if type.to_s == "http" && source.key?("body") && !source.key?("body_field")
59
+ source.delete("body")
60
+ source["body_field"] = "body"
61
+ end
62
+ source
63
+ end
64
+
65
+ def migrate_tool(tool)
66
+ source = Utils.stringify_keys(Utils.deep_copy(Utils.hash!(tool, "tool")))
67
+ source["inputs"] ||= source.delete("parameters") || {}
68
+ source["outputs"] ||= {}
69
+ source["tags"] ||= []
70
+ provider = source.delete("provider") || source.delete("tool_provider")
71
+ source["tool_call_template"] ||= migrate_provider(provider) if provider
72
+ source
73
+ end
74
+
75
+ def migrate_cli_argument(argument)
76
+ converted = argument.to_s.gsub(/\$\{([A-Za-z0-9_]+)\}|\$([A-Za-z0-9_]+)/) do
77
+ "UTCP_ARG_#{Regexp.last_match(1) || Regexp.last_match(2)}_UTCP_END"
78
+ end
79
+ converted.include?("UTCP_ARG_") ? converted : Shellwords.escape(converted)
80
+ end
81
+
82
+ class << self
83
+ alias migrate_config config_v0_1_to_v1_1
84
+ alias migrate_manual manual_v0_1_to_v1_1
85
+ end
86
+ end
87
+ end
88
+