rb-utcp 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,222 @@
1
+ # frozen_string_literal: true
2
+ require "socket"
3
+ require "openssl"
4
+ require "digest/sha1"
5
+ require "base64"
6
+ require "uri"
7
+ require_relative "../utils/subst"
8
+ require_relative "../errors"
9
+ require_relative "base_provider"
10
+
11
+ module Utcp
12
+ module Providers
13
+ # Minimal RFC 6455 WebSocket client (text frames only)
14
+ # Supports ws:// and wss:// (TLS). Client frames are masked; server frames are unmasked.
15
+ class WebSocketProvider < BaseProvider
16
+ GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11".freeze
17
+
18
+ def initialize(name:, auth: nil)
19
+ super(name: name, provider_type: "websocket", auth: auth)
20
+ end
21
+
22
+ def discover_tools!
23
+ raise ProviderError, "WebSocket is an execution provider only"
24
+ end
25
+
26
+ # tool.provider expects:
27
+ # { "provider_type":"websocket", "url":"ws(s)://host/path", "message_template": "...optional..." ,
28
+ # "close_after_ms": 3000, "max_frames": 1 }
29
+ # If a block is given, yields each received text frame (streaming).
30
+ def call_tool(tool, arguments = {}, &block)
31
+ p = tool.provider
32
+ uri = URI(Utils::Subst.apply(p["url"]))
33
+ raise ConfigError, "WebSocket requires ws:// or wss:// URL" unless %w[ws wss].include?(uri.scheme)
34
+
35
+ sock = connect_socket(uri)
36
+ begin
37
+ handshake(sock, uri)
38
+ # Compose a text message to send
39
+ payload = compose_payload(p, arguments)
40
+ if payload
41
+ send_text(sock, payload)
42
+ end
43
+
44
+ # Read frames; stop based on configuration
45
+ close_after = (p["close_after_ms"] || 3000).to_i
46
+ max_frames = (p["max_frames"] || 1).to_i
47
+ deadline = Time.now + (close_after / 1000.0)
48
+ frames = []
49
+ while Time.now < deadline && frames.length < max_frames
50
+ frame = recv_frame(sock, deadline: deadline)
51
+ break unless frame
52
+ case frame[:opcode]
53
+ when 0x1 # text
54
+ if block_given?
55
+ yield frame[:payload]
56
+ else
57
+ frames << frame[:payload]
58
+ end
59
+ when 0x8 # close
60
+ break
61
+ when 0x9 # ping
62
+ send_pong(sock, frame[:payload])
63
+ when 0xA # pong
64
+ # ignore
65
+ else
66
+ # ignore other opcodes
67
+ end
68
+ end
69
+ block_given? ? nil : frames
70
+ ensure
71
+ begin
72
+ send_close(sock)
73
+ rescue
74
+ end
75
+ sock.close rescue nil
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def connect_socket(uri)
82
+ host = uri.host
83
+ port = uri.port || (uri.scheme == "wss" ? 443 : 80)
84
+ tcp = TCPSocket.new(host, port)
85
+ if uri.scheme == "wss"
86
+ ctx = OpenSSL::SSL::SSLContext.new
87
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
88
+ ssl.hostname = host
89
+ ssl.sync_close = true
90
+ ssl.connect
91
+ ssl
92
+ else
93
+ tcp
94
+ end
95
+ end
96
+
97
+ def handshake(sock, uri)
98
+ key = Base64.strict_encode64(Random.new.bytes(16))
99
+ path = uri.request_uri
100
+ host = uri.host
101
+ headers = [
102
+ "GET #{path} HTTP/1.1",
103
+ "Host: #{host}",
104
+ "Upgrade: websocket",
105
+ "Connection: Upgrade",
106
+ "Sec-WebSocket-Key: #{key}",
107
+ "Sec-WebSocket-Version: 13",
108
+ "", ""
109
+ ].join("\r\n")
110
+ sock.write(headers)
111
+
112
+ status_line = sock.gets("\r\n") || ""
113
+ unless status_line.start_with?("HTTP/1.1 101")
114
+ raise ProviderError, "WebSocket handshake failed: #{status_line.strip}"
115
+ end
116
+
117
+ # read headers until blank line
118
+ while (line = sock.gets("\r\n"))
119
+ line = line.strip
120
+ break if line.empty?
121
+ end
122
+ # Validation of Sec-WebSocket-Accept skipped for brevity
123
+ end
124
+
125
+ def compose_payload(p, arguments)
126
+ args = Utils::Subst.apply(arguments || {})
127
+ if p["message_template"].is_a?(String)
128
+ tmpl = p["message_template"]
129
+ # simple ${key} substitution
130
+ tmpl.gsub(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/) { |m| args[$1] || ENV[$1] || m }
131
+ elsif args && !args.empty?
132
+ require "json"
133
+ JSON.dump(args)
134
+ else
135
+ nil
136
+ end
137
+ end
138
+
139
+ def send_text(sock, text)
140
+ data = text.b
141
+ header = [0x81].pack("C") # FIN=1, opcode=1
142
+ mask_flag = 0x80
143
+ len = data.bytesize
144
+ if len < 126
145
+ header << (mask_flag | len).chr
146
+ elsif len < 65536
147
+ header << (mask_flag | 126).chr << [len].pack("n")
148
+ else
149
+ header << (mask_flag | 127).chr << [len].pack("Q>")
150
+ end
151
+ mask = Random.new.bytes(4)
152
+ masked = data.bytes.each_with_index.map { |b, i| (b ^ mask.getbyte(i % 4)) }.pack("C*")
153
+ sock.write(header + mask + masked)
154
+ end
155
+
156
+ def recv_frame(sock, deadline: Time.now + 3)
157
+ header = read_bytes(sock, 2, deadline) or return nil
158
+ b1, b2 = header.bytes
159
+ fin = (b1 & 0x80) != 0
160
+ opcode = b1 & 0x0f
161
+ mask = (b2 & 0x80) != 0
162
+ length = b2 & 0x7f
163
+ if length == 126
164
+ ext = read_bytes(sock, 2, deadline) or return nil
165
+ length = ext.unpack1("n")
166
+ elsif length == 127
167
+ ext = read_bytes(sock, 8, deadline) or return nil
168
+ length = ext.unpack1("Q>")
169
+ end
170
+ mask_key = mask ? read_bytes(sock, 4, deadline) : nil
171
+ payload = read_bytes(sock, length, deadline) or return nil
172
+ if mask_key
173
+ payload = payload.bytes.each_with_index.map { |b, i| (b ^ mask_key.getbyte(i % 4)) }.pack("C*")
174
+ end
175
+ if opcode == 0x1 # text
176
+ payload = payload.force_encoding("UTF-8")
177
+ end
178
+ { fin: fin, opcode: opcode, payload: payload }
179
+ end
180
+
181
+ def send_pong(sock, payload="")
182
+ frame = [0x8A].pack("C") # FIN=1 opcode=0xA pong
183
+ mask_flag = 0x80
184
+ len = payload.bytesize
185
+ if len < 126
186
+ frame << (mask_flag | len).chr
187
+ else
188
+ frame << (mask_flag | 126).chr << [len].pack("n")
189
+ end
190
+ mask = Random.new.bytes(4)
191
+ masked = payload.bytes.each_with_index.map { |b, i| (b ^ mask.getbyte(i % 4)) }.pack("C*")
192
+ sock.write(frame + mask + masked)
193
+ end
194
+
195
+ def send_close(sock)
196
+ frame = [0x88, 0x80, 0x00, 0x00, 0x00, 0x00].pack("C*") # masked empty close
197
+ sock.write(frame) rescue nil
198
+ end
199
+
200
+ def read_bytes(sock, n, deadline)
201
+ buf = +"".b
202
+ while buf.bytesize < n
203
+ timeout = [deadline - Time.now, 0].max
204
+ return nil if timeout <= 0
205
+ ready = IO.select([sock], nil, nil, timeout)
206
+ return nil unless ready
207
+
208
+ begin
209
+ chunk = sock.readpartial(n - buf.bytesize)
210
+ rescue EOFError, IOError, SystemCallError
211
+ return nil
212
+ end
213
+
214
+ return nil if chunk.nil? || chunk.empty?
215
+ buf << chunk
216
+ end
217
+ buf
218
+ end
219
+
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+ module Utcp
3
+ class Search
4
+ def initialize(repo)
5
+ @repo = repo
6
+ end
7
+
8
+ # returns [ [score, tool_full_name], ... ] sorted desc
9
+ def search(query, limit: 5)
10
+ q = query.to_s.downcase
11
+ scores = []
12
+ @repo.all_tools.each do |t|
13
+ text = [t.name, t.description, (t.tags || []).join(" ")].join(" ").downcase
14
+ score = 0
15
+ q.split.each { |w| score += 3 if text.include?(w) }
16
+ scores << [score, t]
17
+ end
18
+ scores.select { |s, _| s > 0 }.sort_by { |s, _| -s }[0, limit].map { |s, t| [s, t] }
19
+ end
20
+ end
21
+ end
data/lib/utcp/tool.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+
4
+ module Utcp
5
+ Tool = Struct.new(:name, :description, :inputs, :outputs, :tags, :provider, keyword_init: true) do
6
+ def full_name(provider_name)
7
+ "#{provider_name}.#{name}"
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+ require_relative "errors"
3
+
4
+ module Utcp
5
+ class ToolRepository
6
+ def initialize
7
+ @tools = {} # full_name => Tool
8
+ @by_provider = {} # provider_name => [Tool]
9
+ end
10
+
11
+ def save_provider_with_tools(provider_name, tools)
12
+ @by_provider[provider_name] = tools
13
+ tools.each do |t|
14
+ @tools["#{provider_name}.#{t.name}"] = t
15
+ end
16
+ end
17
+
18
+ def providers
19
+ @by_provider.keys
20
+ end
21
+
22
+ def find(full_tool_name)
23
+ @tools[full_tool_name] or raise NotFoundError, "Tool not found: #{full_tool_name}"
24
+ end
25
+
26
+ def all_tools
27
+ @tools.values
28
+ end
29
+
30
+ def remove_provider(provider_name)
31
+ list = @by_provider.delete(provider_name) || []
32
+ list.each { |t| @tools.delete("#{provider_name}.#{t.name}") }
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,27 @@
1
+ # lib/utcp/utils/env_loader.rb
2
+ # frozen_string_literal: true
3
+ module Utcp
4
+ module Utils
5
+ class EnvLoader
6
+ # Loads simple KEY=VALUE pairs into ENV (no interpolation here)
7
+ def self.load_file(path = ".env")
8
+ return {} unless File.file?(path)
9
+ vars = {}
10
+ File.readlines(path, chomp: true).each do |line|
11
+ next if line.strip.empty? || line.strip.start_with?("#")
12
+ key, value = line.split("=", 2)
13
+ next unless key
14
+ value ||= ""
15
+ value = value.strip
16
+ # remove optional surrounding quotes
17
+ value = value.gsub(/\A"(.*)"\z/, '\1')
18
+ value = value.gsub(/\A'(.*)'\z/, '\1')
19
+ key = key.strip
20
+ ENV[key] = value
21
+ vars[key] = value
22
+ end
23
+ vars
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+
4
+ module Utcp
5
+ module Utils
6
+ module Subst
7
+ VAR_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/.freeze
8
+
9
+ module_function
10
+
11
+ def apply(obj, vars = ENV)
12
+ case obj
13
+ when String
14
+ obj.gsub(VAR_RE) { |m| vars[$1] || m }
15
+ when Array
16
+ obj.map { |x| apply(x, vars) }
17
+ when Hash
18
+ obj.transform_values { |v| apply(v, vars) }
19
+ else
20
+ obj
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ module Utcp
3
+ VERSION = "0.1.0"
4
+ end
data/lib/utcp.rb ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+ require_relative "utcp/version"
3
+ require_relative "utcp/errors"
4
+ require_relative "utcp/utils/env_loader"
5
+ require_relative "utcp/utils/subst"
6
+ require_relative "utcp/auth"
7
+ require_relative "utcp/tool"
8
+ require_relative "utcp/tool_repository"
9
+ require_relative "utcp/search"
10
+ require_relative "utcp/providers/base_provider"
11
+ require_relative "utcp/providers/http_provider"
12
+ require_relative "utcp/providers/sse_provider"
13
+ require_relative "utcp/providers/http_stream_provider"
14
+ require_relative "utcp/client"
15
+
16
+ require_relative "utcp/providers/websocket_provider"
17
+ require_relative "utcp/providers/graphql_provider"
18
+ require_relative "utcp/providers/tcp_provider"
19
+ require_relative "utcp/providers/udp_provider"
20
+ require_relative "utcp/providers/cli_provider"
21
+ require_relative "utcp/providers/mcp_provider"
metadata ADDED
@@ -0,0 +1,67 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rb-utcp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - UTCP contributors
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-08-12 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Minimal Ruby implementation of UTCP with HTTP/SSE/HTTP-stream transports.
14
+ email:
15
+ - dev@utcp.io
16
+ executables:
17
+ - utcp
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - LICENSE
22
+ - README.md
23
+ - bin/utcp
24
+ - lib/utcp.rb
25
+ - lib/utcp/auth.rb
26
+ - lib/utcp/client.rb
27
+ - lib/utcp/errors.rb
28
+ - lib/utcp/providers/base_provider.rb
29
+ - lib/utcp/providers/cli_provider.rb
30
+ - lib/utcp/providers/graphql_provider.rb
31
+ - lib/utcp/providers/http_provider.rb
32
+ - lib/utcp/providers/http_stream_provider.rb
33
+ - lib/utcp/providers/mcp_provider.rb
34
+ - lib/utcp/providers/sse_provider.rb
35
+ - lib/utcp/providers/tcp_provider.rb
36
+ - lib/utcp/providers/udp_provider.rb
37
+ - lib/utcp/providers/websocket_provider.rb
38
+ - lib/utcp/search.rb
39
+ - lib/utcp/tool.rb
40
+ - lib/utcp/tool_repository.rb
41
+ - lib/utcp/utils/env_loader.rb
42
+ - lib/utcp/utils/subst.rb
43
+ - lib/utcp/version.rb
44
+ homepage:
45
+ licenses:
46
+ - MPL-2.0
47
+ metadata: {}
48
+ post_install_message:
49
+ rdoc_options: []
50
+ require_paths:
51
+ - lib
52
+ required_ruby_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: 3.0.0
57
+ required_rubygems_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ requirements: []
63
+ rubygems_version: 3.5.18
64
+ signing_key:
65
+ specification_version: 4
66
+ summary: Universal Tool Calling Protocol (Ruby, alpha)
67
+ test_files: []