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,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