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.
- checksums.yaml +7 -0
- data/LICENSE +6 -0
- data/README.md +129 -0
- data/bin/utcp +61 -0
- data/lib/utcp/auth.rb +111 -0
- data/lib/utcp/client.rb +166 -0
- data/lib/utcp/errors.rb +8 -0
- data/lib/utcp/providers/base_provider.rb +24 -0
- data/lib/utcp/providers/cli_provider.rb +40 -0
- data/lib/utcp/providers/graphql_provider.rb +52 -0
- data/lib/utcp/providers/http_provider.rb +122 -0
- data/lib/utcp/providers/http_stream_provider.rb +52 -0
- data/lib/utcp/providers/mcp_provider.rb +140 -0
- data/lib/utcp/providers/sse_provider.rb +61 -0
- data/lib/utcp/providers/tcp_provider.rb +83 -0
- data/lib/utcp/providers/udp_provider.rb +65 -0
- data/lib/utcp/providers/websocket_provider.rb +222 -0
- data/lib/utcp/search.rb +21 -0
- data/lib/utcp/tool.rb +10 -0
- data/lib/utcp/tool_repository.rb +35 -0
- data/lib/utcp/utils/env_loader.rb +27 -0
- data/lib/utcp/utils/subst.rb +25 -0
- data/lib/utcp/version.rb +4 -0
- data/lib/utcp.rb +21 -0
- metadata +67 -0
|
@@ -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
|
data/lib/utcp/search.rb
ADDED
|
@@ -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,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
|
data/lib/utcp/version.rb
ADDED
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: []
|