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,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "uri"
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require_relative "../utils/subst"
|
|
6
|
+
require_relative "../errors"
|
|
7
|
+
require_relative "../tool"
|
|
8
|
+
require_relative "../auth"
|
|
9
|
+
require_relative "base_provider"
|
|
10
|
+
|
|
11
|
+
module Utcp
|
|
12
|
+
module Providers
|
|
13
|
+
class HttpProvider < BaseProvider
|
|
14
|
+
def initialize(name:, url:, http_method: "GET", content_type: "application/json", headers: {}, manual: false, auth: nil, body_field: nil)
|
|
15
|
+
super(name: name, provider_type: manual ? "http_manual" : "http", auth: auth)
|
|
16
|
+
@url = Utils::Subst.apply(url)
|
|
17
|
+
@http_method = http_method.upcase
|
|
18
|
+
@content_type = content_type
|
|
19
|
+
@headers = Utils::Subst.apply(headers || {})
|
|
20
|
+
@manual = manual
|
|
21
|
+
@body_field = body_field
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def discover_tools!
|
|
25
|
+
raise ProviderError, "Not a manual provider" unless @manual
|
|
26
|
+
uri = URI(@url)
|
|
27
|
+
req = Net::HTTP.const_get(@http_method.capitalize).new(uri)
|
|
28
|
+
headers = default_headers
|
|
29
|
+
apply_auth!(uri, headers)
|
|
30
|
+
headers.each { |k, v| req[k] = v }
|
|
31
|
+
|
|
32
|
+
http = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https")
|
|
33
|
+
begin
|
|
34
|
+
res = http.request(req)
|
|
35
|
+
raise ProviderError, "Manual fetch failed: #{res.code}" unless res.is_a?(Net::HTTPSuccess)
|
|
36
|
+
manual = JSON.parse(res.body)
|
|
37
|
+
to_tools(manual)
|
|
38
|
+
ensure
|
|
39
|
+
http.finish if http.active?
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def call_tool(tool, arguments = {}, &block)
|
|
44
|
+
# tool.provider is a hash containing execution provider details
|
|
45
|
+
p = tool.provider
|
|
46
|
+
url = Utils::Subst.apply(p["url"] || @url)
|
|
47
|
+
method = (p["http_method"] || @http_method || "GET").upcase
|
|
48
|
+
content_type = p["content_type"] || @content_type || "application/json"
|
|
49
|
+
headers = Utils::Subst.apply(p["headers"] || {}).merge(default_headers)
|
|
50
|
+
body_field = p["body_field"] || @body_field
|
|
51
|
+
|
|
52
|
+
uri = URI(url)
|
|
53
|
+
args = Utils::Subst.apply(arguments || {})
|
|
54
|
+
if %w[GET DELETE].include?(method)
|
|
55
|
+
q = URI.decode_www_form(uri.query || "") + args.to_a
|
|
56
|
+
uri.query = URI.encode_www_form(q)
|
|
57
|
+
req = Net::HTTP.const_get(method.capitalize).new(uri)
|
|
58
|
+
else
|
|
59
|
+
req = Net::HTTP.const_get(method.capitalize).new(uri)
|
|
60
|
+
if body_field
|
|
61
|
+
payload = { body_field => args }
|
|
62
|
+
else
|
|
63
|
+
payload = args
|
|
64
|
+
end
|
|
65
|
+
if content_type.include?("json")
|
|
66
|
+
req.body = JSON.dump(payload)
|
|
67
|
+
req["Content-Type"] = "application/json"
|
|
68
|
+
else
|
|
69
|
+
req.body = URI.encode_www_form(payload)
|
|
70
|
+
req["Content-Type"] = "application/x-www-form-urlencoded"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# auth
|
|
75
|
+
headers = headers.transform_keys(&:to_s)
|
|
76
|
+
apply_auth!(uri, headers)
|
|
77
|
+
req["Cookie"] = [req["Cookie"], @auth&.apply_cookies].compact.join("; ") if @auth.respond_to?(:apply_cookies)
|
|
78
|
+
headers.each { |k, v| req[k] = v }
|
|
79
|
+
|
|
80
|
+
http = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https")
|
|
81
|
+
begin
|
|
82
|
+
res = http.request(req)
|
|
83
|
+
# try to parse as JSON; fall back to raw string
|
|
84
|
+
begin
|
|
85
|
+
JSON.parse(res.body)
|
|
86
|
+
rescue
|
|
87
|
+
res.body
|
|
88
|
+
end
|
|
89
|
+
ensure
|
|
90
|
+
http.finish if http.active?
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def default_headers
|
|
97
|
+
{ "User-Agent" => "ruby-utcp/#{Utcp::VERSION}" }.merge(@headers || {})
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def apply_auth!(uri, headers)
|
|
101
|
+
if @auth
|
|
102
|
+
@auth.apply_query(uri) if @auth.respond_to?(:apply_query)
|
|
103
|
+
@auth.apply_headers(headers)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def to_tools(manual)
|
|
108
|
+
tools = (manual["tools"] || []).map do |t|
|
|
109
|
+
Utcp::Tool.new(
|
|
110
|
+
name: t["name"],
|
|
111
|
+
description: t["description"],
|
|
112
|
+
inputs: t["inputs"],
|
|
113
|
+
outputs: t["outputs"],
|
|
114
|
+
tags: t["tags"] || [],
|
|
115
|
+
provider: t["tool_provider"] || {}
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
tools
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "uri"
|
|
3
|
+
require "net/http"
|
|
4
|
+
require_relative "../utils/subst"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
require_relative "base_provider"
|
|
7
|
+
|
|
8
|
+
module Utcp
|
|
9
|
+
module Providers
|
|
10
|
+
# Simple HTTP chunked transfer streaming
|
|
11
|
+
class HttpStreamProvider < BaseProvider
|
|
12
|
+
def initialize(name:, auth: nil)
|
|
13
|
+
super(name: name, provider_type: "http_stream", auth: auth)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def discover_tools!
|
|
17
|
+
raise ProviderError, "HTTP stream is an execution provider only"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call_tool(tool, arguments = {}, &block)
|
|
21
|
+
p = tool.provider
|
|
22
|
+
url = Utils::Subst.apply(p["url"])
|
|
23
|
+
method = (p["http_method"] || "GET").upcase
|
|
24
|
+
uri = URI(url)
|
|
25
|
+
|
|
26
|
+
args = Utils::Subst.apply(arguments || {})
|
|
27
|
+
if %w[GET DELETE].include?(method)
|
|
28
|
+
q = URI.decode_www_form(uri.query || "") + args.to_a
|
|
29
|
+
uri.query = URI.encode_www_form(q)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
req = Net::HTTP.const_get(method.capitalize).new(uri)
|
|
33
|
+
headers = { "Accept" => "application/json" }
|
|
34
|
+
@auth&.apply_query(uri) if @auth&.respond_to?(:apply_query)
|
|
35
|
+
@auth&.apply_headers(headers)
|
|
36
|
+
headers.each { |k, v| req[k] = v }
|
|
37
|
+
|
|
38
|
+
http = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https")
|
|
39
|
+
begin
|
|
40
|
+
http.request(req) do |res|
|
|
41
|
+
res.read_body do |chunk|
|
|
42
|
+
yield chunk if block_given?
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
nil
|
|
46
|
+
ensure
|
|
47
|
+
http.finish if http.active?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "uri"
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require_relative "../utils/subst"
|
|
6
|
+
require_relative "../errors"
|
|
7
|
+
require_relative "../auth"
|
|
8
|
+
require_relative "../tool"
|
|
9
|
+
require_relative "base_provider"
|
|
10
|
+
|
|
11
|
+
module Utcp
|
|
12
|
+
module Providers
|
|
13
|
+
# Minimal HTTP-based MCP provider.
|
|
14
|
+
# Works in two modes:
|
|
15
|
+
# - Manual discovery: GET {url}{discovery_path} returns a UTCP manual (tools array).
|
|
16
|
+
# - Execution: POST {url}{call_path} with {"tool": name, "arguments": {...}}.
|
|
17
|
+
#
|
|
18
|
+
# Streaming:
|
|
19
|
+
# - If the server replies with 'text/event-stream', we'll parse SSE 'data:' lines and yield them.
|
|
20
|
+
# - Otherwise, if a block is given, chunks from the HTTP body are yielded as they arrive.
|
|
21
|
+
class McpProvider < BaseProvider
|
|
22
|
+
def initialize(name:, url:, headers: {}, auth: nil, manual: false, discovery_path: "/manual", call_path: "/call")
|
|
23
|
+
super(name: name, provider_type: manual ? "mcp_manual" : "mcp", auth: auth)
|
|
24
|
+
@url = Utils::Subst.apply(url)
|
|
25
|
+
@headers = Utils::Subst.apply(headers || {})
|
|
26
|
+
@manual = manual
|
|
27
|
+
@discovery_path = discovery_path
|
|
28
|
+
@call_path = call_path
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def discover_tools!
|
|
32
|
+
raise ProviderError, "Not a manual provider" unless @manual
|
|
33
|
+
uri = URI(join_path(@url, @discovery_path))
|
|
34
|
+
req = Net::HTTP::Get.new(uri)
|
|
35
|
+
headers = default_headers
|
|
36
|
+
apply_auth!(uri, headers)
|
|
37
|
+
headers.each { |k, v| req[k] = v }
|
|
38
|
+
|
|
39
|
+
http = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https")
|
|
40
|
+
begin
|
|
41
|
+
res = http.request(req)
|
|
42
|
+
raise ProviderError, "MCP discovery failed: #{res.code}" unless res.is_a?(Net::HTTPSuccess)
|
|
43
|
+
manual = JSON.parse(res.body)
|
|
44
|
+
to_tools(manual)
|
|
45
|
+
ensure
|
|
46
|
+
http.finish if http.active?
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Expects tool.provider to include MCP endpoint info:
|
|
51
|
+
# { "provider_type": "mcp", "url": "http://host:port/mcp",
|
|
52
|
+
# "call_path": "/call", "headers": { ... } }
|
|
53
|
+
def call_tool(tool, arguments = {}, &block)
|
|
54
|
+
p = tool.provider || {}
|
|
55
|
+
base = Utils::Subst.apply(p["url"] || @url)
|
|
56
|
+
call_path = p["call_path"] || @call_path
|
|
57
|
+
uri = URI(join_path(base, call_path))
|
|
58
|
+
|
|
59
|
+
body = { "tool" => tool.name, "arguments" => Utils::Subst.apply(arguments || {}) }
|
|
60
|
+
req = Net::HTTP::Post.new(uri)
|
|
61
|
+
headers = default_headers.merge({ "Content-Type" => "application/json" }).merge(Utils::Subst.apply(p["headers"] || {}))
|
|
62
|
+
apply_auth!(uri, headers)
|
|
63
|
+
headers.each { |k, v| req[k] = v }
|
|
64
|
+
req.body = JSON.dump(body)
|
|
65
|
+
|
|
66
|
+
http = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https")
|
|
67
|
+
begin
|
|
68
|
+
if block_given?
|
|
69
|
+
http.request(req) do |res|
|
|
70
|
+
ctype = (res["Content-Type"] || "").downcase
|
|
71
|
+
if ctype.include?("text/event-stream")
|
|
72
|
+
buffer = +""
|
|
73
|
+
res.read_body do |chunk|
|
|
74
|
+
buffer << chunk
|
|
75
|
+
while (line = buffer.slice!(/.*\n/))
|
|
76
|
+
line = line.strip
|
|
77
|
+
next if line.empty? || line.start_with?(":")
|
|
78
|
+
if line.start_with?("data:")
|
|
79
|
+
data = line.sub(/^data:\s?/, "")
|
|
80
|
+
yield data
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
else
|
|
85
|
+
res.read_body do |chunk|
|
|
86
|
+
yield chunk
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
nil
|
|
91
|
+
else
|
|
92
|
+
res = http.request(req)
|
|
93
|
+
begin
|
|
94
|
+
JSON.parse(res.body)
|
|
95
|
+
rescue
|
|
96
|
+
res.body
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
ensure
|
|
100
|
+
http.finish if http.active?
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def default_headers
|
|
107
|
+
{ "User-Agent" => "ruby-utcp/#{Utcp::VERSION}" }.merge(@headers || {})
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def apply_auth!(uri, headers)
|
|
111
|
+
if @auth
|
|
112
|
+
@auth.apply_query(uri) if @auth.respond_to?(:apply_query)
|
|
113
|
+
@auth.apply_headers(headers)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def to_tools(manual)
|
|
118
|
+
(manual["tools"] || []).map do |t|
|
|
119
|
+
Utcp::Tool.new(
|
|
120
|
+
name: t["name"],
|
|
121
|
+
description: t["description"],
|
|
122
|
+
inputs: t["inputs"],
|
|
123
|
+
outputs: t["outputs"],
|
|
124
|
+
tags: t["tags"] || [],
|
|
125
|
+
provider: t["tool_provider"] || {}
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def join_path(base, path)
|
|
131
|
+
return base.to_s if path.to_s.empty?
|
|
132
|
+
if base.end_with?("/")
|
|
133
|
+
base + path.sub(%r{^/}, "")
|
|
134
|
+
else
|
|
135
|
+
base + path
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "uri"
|
|
3
|
+
require "net/http"
|
|
4
|
+
require_relative "../utils/subst"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
require_relative "base_provider"
|
|
7
|
+
|
|
8
|
+
module Utcp
|
|
9
|
+
module Providers
|
|
10
|
+
# Execution provider for Server-Sent Events (SSE)
|
|
11
|
+
class SseProvider < BaseProvider
|
|
12
|
+
def initialize(name:, auth: nil)
|
|
13
|
+
super(name: name, provider_type: "sse", auth: auth)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# manual discovery not supported here
|
|
17
|
+
def discover_tools!
|
|
18
|
+
raise ProviderError, "SSE is an execution provider only"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Expects tool.provider to have: { "url": "...", "http_method": "GET" }
|
|
22
|
+
def call_tool(tool, arguments = {}, &block)
|
|
23
|
+
p = tool.provider
|
|
24
|
+
url = Utils::Subst.apply(p["url"])
|
|
25
|
+
uri = URI(url)
|
|
26
|
+
# add args as query
|
|
27
|
+
args = Utils::Subst.apply(arguments || {})
|
|
28
|
+
q = URI.decode_www_form(uri.query || "") + args.to_a
|
|
29
|
+
uri.query = URI.encode_www_form(q)
|
|
30
|
+
|
|
31
|
+
req = Net::HTTP::Get.new(uri)
|
|
32
|
+
headers = { "Accept" => "text/event-stream" }
|
|
33
|
+
@auth&.apply_query(uri) if @auth&.respond_to?(:apply_query)
|
|
34
|
+
@auth&.apply_headers(headers)
|
|
35
|
+
|
|
36
|
+
headers.each { |k, v| req[k] = v }
|
|
37
|
+
|
|
38
|
+
http = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https")
|
|
39
|
+
begin
|
|
40
|
+
buffer = +""
|
|
41
|
+
http.request(req) do |res|
|
|
42
|
+
res.read_body do |chunk|
|
|
43
|
+
buffer << chunk
|
|
44
|
+
while (line = buffer.slice!(/.*\n/))
|
|
45
|
+
line = line.strip
|
|
46
|
+
next if line.empty? || line.start_with?(":")
|
|
47
|
+
if line.start_with?("data:")
|
|
48
|
+
data = line.sub(/^data:\s?/, "")
|
|
49
|
+
yield data if block_given?
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
nil
|
|
55
|
+
ensure
|
|
56
|
+
http.finish if http.active?
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "socket"
|
|
3
|
+
require_relative "../utils/subst"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
require_relative "base_provider"
|
|
6
|
+
|
|
7
|
+
module Utcp
|
|
8
|
+
module Providers
|
|
9
|
+
# Raw TCP provider
|
|
10
|
+
# tool.provider: { "provider_type":"tcp", "host":"127.0.0.1","port":5001,
|
|
11
|
+
# "message_template":"hello ${name}\n",
|
|
12
|
+
# "read_until":"\n", "timeout_ms": 2000 }
|
|
13
|
+
class TcpProvider < BaseProvider
|
|
14
|
+
def initialize(name:)
|
|
15
|
+
super(name: name, provider_type: "tcp", auth: nil)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def discover_tools!
|
|
19
|
+
raise ProviderError, "TCP is an execution provider only"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call_tool(tool, arguments = {}, &block)
|
|
23
|
+
p = tool.provider
|
|
24
|
+
host = p["host"] || "127.0.0.1"
|
|
25
|
+
port = (p["port"] || 5001).to_i
|
|
26
|
+
timeout = (p["timeout_ms"] || 2000).to_i / 1000.0
|
|
27
|
+
msg = compose_message(p, arguments)
|
|
28
|
+
|
|
29
|
+
socket = TCPSocket.new(host, port)
|
|
30
|
+
begin
|
|
31
|
+
socket.write(msg) if msg
|
|
32
|
+
if block_given?
|
|
33
|
+
stream_read(socket, timeout: timeout) { |chunk| yield chunk }
|
|
34
|
+
nil
|
|
35
|
+
else
|
|
36
|
+
read_until = p["read_until"]
|
|
37
|
+
if read_until
|
|
38
|
+
read_until_delim(socket, read_until, timeout: timeout)
|
|
39
|
+
else
|
|
40
|
+
socket.read
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
ensure
|
|
44
|
+
socket.close rescue nil
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def compose_message(p, arguments)
|
|
51
|
+
args = Utils::Subst.apply(arguments || {})
|
|
52
|
+
if p["message_template"]
|
|
53
|
+
tmpl = p["message_template"]
|
|
54
|
+
tmpl.gsub(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/) { |m| args[$1] || ENV[$1] || m }
|
|
55
|
+
else
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def stream_read(sock, timeout: 2)
|
|
61
|
+
loop do
|
|
62
|
+
ready = IO.select([sock], nil, nil, timeout)
|
|
63
|
+
break unless ready
|
|
64
|
+
chunk = sock.read_nonblock(4096, exception: false)
|
|
65
|
+
break unless chunk
|
|
66
|
+
yield chunk
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def read_until_delim(sock, delim, timeout: 2)
|
|
71
|
+
buf = +"".b
|
|
72
|
+
loop do
|
|
73
|
+
ready = IO.select([sock], nil, nil, timeout)
|
|
74
|
+
break unless ready
|
|
75
|
+
c = sock.readpartial(1) rescue break
|
|
76
|
+
buf << c
|
|
77
|
+
break if buf.end_with?(delim)
|
|
78
|
+
end
|
|
79
|
+
buf
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "socket"
|
|
3
|
+
require_relative "../utils/subst"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
require_relative "base_provider"
|
|
6
|
+
|
|
7
|
+
module Utcp
|
|
8
|
+
module Providers
|
|
9
|
+
# UDP provider (best-effort, may drop packets)
|
|
10
|
+
# tool.provider: { "provider_type":"udp", "host":"127.0.0.1","port":5002,
|
|
11
|
+
# "message_template":"ping ${name}", "timeout_ms": 1000, "max_bytes": 2048 }
|
|
12
|
+
class UdpProvider < BaseProvider
|
|
13
|
+
def initialize(name:)
|
|
14
|
+
super(name: name, provider_type: "udp", auth: nil)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def discover_tools!
|
|
18
|
+
raise ProviderError, "UDP is an execution provider only"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call_tool(tool, arguments = {}, &block)
|
|
22
|
+
p = tool.provider
|
|
23
|
+
host = p["host"] || "127.0.0.1"
|
|
24
|
+
port = (p["port"] || 5002).to_i
|
|
25
|
+
timeout = (p["timeout_ms"] || 1000).to_i / 1000.0
|
|
26
|
+
max_bytes = (p["max_bytes"] || 2048).to_i
|
|
27
|
+
msg = compose_message(p, arguments)
|
|
28
|
+
|
|
29
|
+
udp = UDPSocket.new
|
|
30
|
+
udp.connect(host, port)
|
|
31
|
+
begin
|
|
32
|
+
udp.send(msg.to_s, 0)
|
|
33
|
+
if block_given?
|
|
34
|
+
if IO.select([udp], nil, nil, timeout)
|
|
35
|
+
data, _ = udp.recvfrom(max_bytes)
|
|
36
|
+
yield data
|
|
37
|
+
end
|
|
38
|
+
nil
|
|
39
|
+
else
|
|
40
|
+
if IO.select([udp], nil, nil, timeout)
|
|
41
|
+
data, _ = udp.recvfrom(max_bytes)
|
|
42
|
+
data
|
|
43
|
+
else
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
ensure
|
|
48
|
+
udp.close rescue nil
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def compose_message(p, arguments)
|
|
55
|
+
args = Utils::Subst.apply(arguments || {})
|
|
56
|
+
if p["message_template"]
|
|
57
|
+
tmpl = p["message_template"]
|
|
58
|
+
tmpl.gsub(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/) { |m| args[$1] || ENV[$1] || m }
|
|
59
|
+
else
|
|
60
|
+
""
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|