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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f149ed8c39f7f9204dedf270af330dfca4a2a81af20da405e96c04b9b5102968
4
+ data.tar.gz: 4ba6c0e77734d204553d4d5de568870e1cdcab6e95057d185e1f3b653137fda1
5
+ SHA512:
6
+ metadata.gz: fc94f91b7227c7fef8d3a38532ead92eeb8360bcbc5ae8216c31e072f4e5de389121d93ffd1af284cfdcd81a166de159279e5b8d5d2a1739d465365a9c5883ea
7
+ data.tar.gz: 4e6b711fb478658fe17ed4acbe9148b2f25b60e901e3424561a5e55d9488117dbc9e69a4772d7f58de2c241af268a43565bcd701a6591afc1df04af7acad3fdb
data/LICENSE ADDED
@@ -0,0 +1,6 @@
1
+ Mozilla Public License Version 2.0
2
+ ==================================
3
+
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
data/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # ruby-utcp (alpha)
2
+
3
+ A small, dependency-light Ruby implementation of the **Universal Tool Calling Protocol (UTCP)**.
4
+ It mirrors the core models — **Manual**, **Tool**, **Providers**, and **Auth** — and lets you
5
+ discover tools and call them over HTTP, SSE, and HTTP chunked streams.
6
+
7
+ > Status: early alpha, but usable for simple demos. Standard library only.
8
+
9
+ ## Features
10
+ - Load one or more "manual providers" from `providers.json` (HTTP or local file).
11
+ - Store discovered tools in an in-memory repository.
12
+ - Call tools via HTTP (`GET/POST/PUT/PATCH/DELETE`), SSE, or HTTP chunked streaming.
13
+ - API Key, Basic, and OAuth2 Client Credentials auth (token cached in memory).
14
+ - Simple variable substitution for `${VAR}` using values from environment and `.env` files.
15
+ - Tiny search helper scoring tags + description to find relevant tools.
16
+
17
+ ## Install
18
+ This is a vanilla Ruby project. No external gems are required.
19
+ ```bash
20
+ ruby -v # Ruby 3.x recommended
21
+ ```
22
+
23
+ ## Quickstart
24
+ ```bash
25
+ # 1) Unzip, cd in
26
+ cd ruby-utcp
27
+
28
+ # 2) (Optional) create a .env file with secrets
29
+ echo 'OPEN_WEATHER_API_KEY=replace-me' > .env
30
+
31
+ # 3) Run the example (uses httpbin.org)
32
+ ruby examples/basic_call.rb
33
+ ```
34
+
35
+ ## Layout
36
+ ```
37
+ lib/utcp.rb
38
+ lib/utcp/version.rb
39
+ lib/utcp/client.rb
40
+ lib/utcp/tool.rb
41
+ lib/utcp/errors.rb
42
+ lib/utcp/utils/env_loader.rb
43
+ lib/utcp/utils/subst.rb
44
+ lib/utcp/auth.rb
45
+ lib/utcp/tool_repository.rb
46
+ lib/utcp/search.rb
47
+ lib/utcp/providers/base_provider.rb
48
+ lib/utcp/providers/http_provider.rb
49
+ lib/utcp/providers/sse_provider.rb
50
+ lib/utcp/providers/http_stream_provider.rb
51
+ bin/utcp
52
+ examples/providers.json
53
+ examples/tools_weather.json
54
+ examples/basic_call.rb
55
+ ```
56
+
57
+ ## Example manual (local file)
58
+ See `examples/tools_weather.json` for a minimal UTCP manual that exposes two tools:
59
+ - `echo` (POST JSON to httpbin.org)
60
+ - `stream_http` (stream 20 JSON lines from httpbin.org)
61
+
62
+ ## CLI
63
+ ```bash
64
+ # List all discovered tools
65
+ ruby bin/utcp list examples/providers.json
66
+
67
+ # Call a tool (args as JSON)
68
+ ruby bin/utcp call examples/providers.json echo --args '{"message":"hello"}'
69
+ ```
70
+
71
+ ## License
72
+ MPL-2.0
73
+
74
+
75
+ ## New transports (alpha)
76
+ - **WebSocket**: minimal RFC6455 text-only client; great for echo/testing.
77
+ - **GraphQL**: POST query + variables to any GraphQL endpoint.
78
+ - **TCP/UDP**: raw sockets with simple `${var}` templating; includes local echo servers under `examples/dev/`.
79
+ - **CLI**: call local commands (use carefully!).
80
+
81
+ ### Try them
82
+ Start local echo servers (optional, for TCP/UDP):
83
+ ```bash
84
+ ruby examples/dev/echo_tcp_server.rb 5001
85
+ ruby examples/dev/echo_udp_server.rb 5002
86
+ ```
87
+
88
+ Use the extra providers file:
89
+ ```bash
90
+ ruby bin/utcp list examples/providers_extra.json
91
+ ruby bin/utcp call examples/providers_extra.json ws_demo.ws_echo --args '{"text":"hello ws"}' --stream
92
+ ruby bin/utcp call examples/providers_extra.json cli_demo.shell_echo --args '{"msg":"hi from shell"}'
93
+ ruby bin/utcp call examples/providers_extra.json sock_demo.tcp_echo --args '{"name":"kamil"}'
94
+ ruby bin/utcp call examples/providers_extra.json gql_demo.country_by_code --args '{"code":"DE"}'
95
+ ```
96
+
97
+
98
+ ## MCP provider
99
+ This adds a minimal HTTP-based MCP bridge.
100
+
101
+ ### Discovery
102
+ Manual discovery expects the server to return a UTCP manual at `{url}/manual` (configurable via `discovery_path`). Point a manual provider to `"provider_type": "mcp"` in `providers.json` to fetch tools.
103
+
104
+ ### Calls
105
+ Tools with `"provider_type": "mcp"` will POST to `{url}/call` with:
106
+ ```json
107
+ { "tool": "<name>", "arguments": { "...": "..." } }
108
+ ```
109
+ If the response is `text/event-stream` we parse SSE and yield each `data:` line; otherwise we stream raw chunks when `--stream` is used.
110
+
111
+ ### Example
112
+ ```bash
113
+ # assuming an MCP test server on http://localhost:8220/mcp
114
+ ruby bin/utcp list examples/providers_mcp.json
115
+ ruby bin/utcp call examples/providers_mcp.json mcp_demo.hello --args '{"name":"Kamil"}'
116
+ ```
117
+
118
+
119
+ ## Tests
120
+ This project uses **Minitest** (stdlib only).
121
+
122
+ Run all tests:
123
+ ```bash
124
+ ruby bin/test
125
+ ```
126
+ or
127
+ ```bash
128
+ ruby -Ilib -Itest -rminitest/autorun test/*_test.rb
129
+ ```
data/bin/utcp ADDED
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
4
+ require "json"
5
+ require "utcp"
6
+
7
+ usage = <<~TXT
8
+ Usage:
9
+ ruby bin/utcp list <providers.json>
10
+ ruby bin/utcp call <providers.json> <provider.tool> --args '<json>' [--stream]
11
+ TXT
12
+
13
+ cmd = ARGV.shift
14
+ if !cmd || %w[--help -h help].include?(cmd)
15
+ puts usage
16
+ exit 0
17
+ end
18
+
19
+ providers_path = ARGV.shift or abort usage
20
+
21
+ client = Utcp::Client.create({ "providers_file_path" => providers_path })
22
+
23
+ case cmd
24
+ when "list"
25
+ client.repo.providers.each do |p|
26
+ puts "Provider: #{p}"
27
+ end
28
+ client.repo.all_tools.each do |t|
29
+ # We don't store full_name on the tool, so reconstruct from repo map if needed
30
+ # For CLI simplicity, print provider + tool name by scanning repo mapping
31
+ # (small overhead acceptable here)
32
+ client.repo.providers.each do |prov|
33
+ begin
34
+ if client.repo.find("#{prov}.#{t.name}") == t
35
+ puts " - #{prov}.#{t.name} :: #{t.description}"
36
+ end
37
+ rescue
38
+ end
39
+ end
40
+ end
41
+ when "call"
42
+ tool = ARGV.shift or abort usage
43
+ args = {}
44
+ while a = ARGV.shift
45
+ if a == "--args"
46
+ args = JSON.parse(ARGV.shift || "{}") rescue {}
47
+ elsif a == "--stream"
48
+ stream = true
49
+ end
50
+ end
51
+ if stream
52
+ client.call_tool(tool, args, stream: true) do |chunk|
53
+ puts chunk
54
+ end
55
+ else
56
+ res = client.call_tool(tool, args)
57
+ puts JSON.pretty_generate(res) rescue puts(res.to_s)
58
+ end
59
+ else
60
+ abort usage
61
+ end
data/lib/utcp/auth.rb ADDED
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+ require "base64"
3
+ require "json"
4
+ require "uri"
5
+ require "net/http"
6
+ require_relative "errors"
7
+ require_relative "utils/subst"
8
+
9
+ module Utcp
10
+ module Auth
11
+ class Base
12
+ def apply_headers(h) = h
13
+ def apply_query(uri) = uri
14
+ end
15
+
16
+ class ApiKey < Base
17
+ def initialize(api_key:, var_name: "Authorization", location: "header")
18
+ @api_key = Utils::Subst.apply(api_key)
19
+ @var_name = var_name
20
+ @location = location
21
+ end
22
+
23
+ def apply_headers(h)
24
+ return h unless @location == "header"
25
+ h[@var_name] = @api_key
26
+ h
27
+ end
28
+
29
+ def apply_query(uri)
30
+ return uri unless @location == "query"
31
+ q = URI.decode_www_form(uri.query || "") << [@var_name, @api_key]
32
+ uri.query = URI.encode_www_form(q)
33
+ uri
34
+ end
35
+
36
+ def apply_cookies(existing = "")
37
+ return existing unless @location == "cookie"
38
+ cookie = "#{@var_name}=#{@api_key}"
39
+ existing.to_s.empty? ? cookie : "#{existing}; #{cookie}"
40
+ end
41
+ end
42
+
43
+ class Basic < Base
44
+ def initialize(username:, password:)
45
+ @cred = Base64.strict_encode64("#{Utils::Subst.apply(username)}:#{Utils::Subst.apply(password)}")
46
+ end
47
+
48
+ def apply_headers(h)
49
+ h["Authorization"] = "Basic #{@cred}"
50
+ h
51
+ end
52
+ end
53
+
54
+ class OAuth2 < Base
55
+ def initialize(token_url:, client_id:, client_secret:, scope: nil)
56
+ @token_url = Utils::Subst.apply(token_url)
57
+ @client_id = Utils::Subst.apply(client_id)
58
+ @client_secret = Utils::Subst.apply(client_secret)
59
+ @scope = scope && Utils::Subst.apply(scope)
60
+ @cached = nil
61
+ @expires_at = Time.at(0)
62
+ end
63
+
64
+ def apply_headers(h)
65
+ token = fetch_token
66
+ h["Authorization"] = "Bearer #{token}"
67
+ h
68
+ end
69
+
70
+ private
71
+
72
+ def fetch_token
73
+ return @cached if Time.now < @expires_at && @cached
74
+ uri = URI(@token_url)
75
+ req = Net::HTTP::Post.new(uri)
76
+ req["Content-Type"] = "application/x-www-form-urlencoded"
77
+ form = { "grant_type" => "client_credentials", "client_id" => @client_id, "client_secret" => @client_secret }
78
+ form["scope"] = @scope if @scope && !@scope.empty?
79
+ req.body = URI.encode_www_form(form)
80
+
81
+ http = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https")
82
+ begin
83
+ res = http.request(req)
84
+ raise AuthError, "OAuth2 token request failed: #{res.code} #{res.body}" unless res.is_a?(Net::HTTPSuccess)
85
+ data = JSON.parse(res.body)
86
+ @cached = data["access_token"] || data["token"] || data["id_token"]
87
+ ttl = (data["expires_in"] || 3600).to_i
88
+ @expires_at = Time.now + ttl - 30
89
+ @cached or raise AuthError, "OAuth2 response missing token"
90
+ ensure
91
+ http.finish if http.active?
92
+ end
93
+ end
94
+ end
95
+
96
+ def self.from_hash(h)
97
+ return nil unless h.is_a?(Hash)
98
+ type = (h["auth_type"] || h["type"] || "").downcase
99
+ case type
100
+ when "api_key", "apikey"
101
+ ApiKey.new(api_key: h["api_key"] || h["key"] || "", var_name: h["var_name"] || "Authorization", location: (h["location"] || "header"))
102
+ when "basic"
103
+ Basic.new(username: h["username"] || "", password: h["password"] || "")
104
+ when "oauth2"
105
+ OAuth2.new(token_url: h["token_url"] || h["tokenUrl"] || "", client_id: h["client_id"] || "", client_secret: h["client_secret"] || "", scope: h["scope"])
106
+ else
107
+ nil
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,166 @@
1
+ # lib/utcp/client.rb
2
+ # frozen_string_literal: true
3
+ require "json"
4
+ require "uri"
5
+ require_relative "errors"
6
+ require_relative "utils/env_loader"
7
+ require_relative "utils/subst"
8
+ require_relative "tool_repository"
9
+ require_relative "search"
10
+ require_relative "auth"
11
+ require_relative "providers/base_provider"
12
+ require_relative "providers/http_provider"
13
+ require_relative "providers/sse_provider"
14
+ require_relative "providers/http_stream_provider"
15
+ require_relative "providers/websocket_provider"
16
+ require_relative "providers/graphql_provider"
17
+ require_relative "providers/tcp_provider"
18
+ require_relative "providers/udp_provider"
19
+ require_relative "providers/cli_provider"
20
+ require_relative "providers/mcp_provider"
21
+
22
+ module Utcp
23
+ class Client
24
+ def self.create(config = {})
25
+ new(config).tap(&:load_providers!)
26
+ end
27
+
28
+ def initialize(config = {})
29
+ @config = config || {}
30
+ @repo = ToolRepository.new
31
+ env_files = Array(@config["load_variables_from"] || @config[:load_variables_from] || [".env"])
32
+ env_files.each { |f| Utils::EnvLoader.load_file(f) }
33
+ end
34
+
35
+ attr_reader :repo
36
+
37
+ def load_providers!
38
+ path = @config["providers_file_path"] || @config[:providers_file_path]
39
+ raise ConfigError, "providers_file_path required" unless path && File.file?(path)
40
+ arr = JSON.parse(File.read(path))
41
+ arr.each { |prov| register_manual_provider(prov) }
42
+ self
43
+ end
44
+
45
+ def register_manual_provider(prov)
46
+ name = prov["name"] || prov[:name] || "provider"
47
+ type = (prov["provider_type"] || prov[:provider_type] || "http").downcase
48
+ auth = Auth.from_hash(prov["auth"] || prov[:auth])
49
+
50
+ tools = case type
51
+ when "http"
52
+ Providers::HttpProvider
53
+ .new(name: name,
54
+ url: prov["url"] || prov[:url],
55
+ http_method: prov["http_method"] || prov[:http_method] || "GET",
56
+ content_type: prov["content_type"] || "application/json",
57
+ headers: prov["headers"] || {},
58
+ manual: true,
59
+ auth: auth)
60
+ .discover_tools!
61
+ when "mcp"
62
+ Providers::McpProvider
63
+ .new(name: name,
64
+ url: prov["url"] || prov[:url],
65
+ headers: prov["headers"] || {},
66
+ auth: auth,
67
+ manual: true,
68
+ discovery_path: prov["discovery_path"] || "/manual")
69
+ .discover_tools!
70
+ when "text"
71
+ manual_path = prov["file_path"] || prov[:file_path]
72
+ raise ConfigError, "text provider missing file_path" unless manual_path && File.file?(manual_path)
73
+ manual = JSON.parse(File.read(manual_path))
74
+ to_tools(manual)
75
+ else
76
+ raise ConfigError, "Unsupported manual provider type: #{type}"
77
+ end
78
+
79
+ @repo.save_provider_with_tools(name, tools)
80
+ tools
81
+ end
82
+
83
+ def call_tool(full_tool_name, arguments = {}, stream: false, &block)
84
+ t = @repo.find(full_tool_name)
85
+ p = t.provider || {}
86
+ type = (p["provider_type"] || "http").downcase
87
+ auth = Auth.from_hash(p["auth"])
88
+
89
+ case type
90
+ when "http"
91
+ exec = Providers::HttpProvider.new(
92
+ name: full_tool_name,
93
+ url: p["url"],
94
+ http_method: p["http_method"] || "GET",
95
+ content_type: p["content_type"] || "application/json",
96
+ headers: p["headers"] || {},
97
+ manual: false,
98
+ auth: auth,
99
+ body_field: p["body_field"]
100
+ )
101
+ exec.call_tool(t, arguments)
102
+
103
+ when "sse"
104
+ raise ConfigError, "Streaming requires a block for SSE" if stream && !block_given?
105
+ exec = Providers::SseProvider.new(name: full_tool_name, auth: auth)
106
+ exec.call_tool(t, arguments, &block)
107
+
108
+ when "http_stream"
109
+ raise ConfigError, "Streaming requires a block for http_stream" if stream && !block_given?
110
+ exec = Providers::HttpStreamProvider.new(name: full_tool_name, auth: auth)
111
+ exec.call_tool(t, arguments, &block)
112
+
113
+ when "websocket"
114
+ exec = Providers::WebSocketProvider.new(name: full_tool_name, auth: auth)
115
+ exec.call_tool(t, arguments, &block)
116
+
117
+ when "graphql"
118
+ exec = Providers::GraphQLProvider.new(name: full_tool_name, auth: auth)
119
+ exec.call_tool(t, arguments, &block)
120
+
121
+ when "tcp"
122
+ exec = Providers::TcpProvider.new(name: full_tool_name)
123
+ exec.call_tool(t, arguments, &block)
124
+
125
+ when "udp"
126
+ exec = Providers::UdpProvider.new(name: full_tool_name)
127
+ exec.call_tool(t, arguments, &block)
128
+
129
+ when "cli"
130
+ exec = Providers::CliProvider.new(name: full_tool_name)
131
+ exec.call_tool(t, arguments, &block)
132
+
133
+ when "mcp"
134
+ exec = Providers::McpProvider.new(
135
+ name: full_tool_name,
136
+ url: p["url"],
137
+ headers: p["headers"] || {},
138
+ auth: auth
139
+ )
140
+ exec.call_tool(t, arguments, &block)
141
+
142
+ else
143
+ raise ConfigError, "Unsupported execution provider type: #{type}"
144
+ end
145
+ end
146
+
147
+ def search_tools(query, limit: 5)
148
+ Search.new(@repo).search(query, limit: limit)
149
+ end
150
+
151
+ private
152
+
153
+ def to_tools(manual)
154
+ (manual["tools"] || []).map do |t|
155
+ Utcp::Tool.new(
156
+ name: t["name"],
157
+ description: t["description"],
158
+ inputs: t["inputs"],
159
+ outputs: t["outputs"],
160
+ tags: t["tags"] || [],
161
+ provider: t["tool_provider"] || {}
162
+ )
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+ module Utcp
3
+ class Error < StandardError; end
4
+ class ConfigError < Error; end
5
+ class NotFoundError < Error; end
6
+ class AuthError < Error; end
7
+ class ProviderError < Error; end
8
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+ module Utcp
3
+ module Providers
4
+ class BaseProvider
5
+ attr_reader :name, :type, :auth
6
+
7
+ def initialize(name:, provider_type:, auth: nil)
8
+ @name = name
9
+ @type = provider_type
10
+ @auth = auth
11
+ end
12
+
13
+ # Returns [Array<Tool>]
14
+ def discover_tools!
15
+ raise NotImplementedError
16
+ end
17
+
18
+ # Execute a tool, possibly streaming chunks via &block
19
+ def call_tool(tool, arguments = {}, &block)
20
+ raise NotImplementedError
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+ require "open3"
3
+ require_relative "../utils/subst"
4
+ require_relative "../errors"
5
+ require_relative "base_provider"
6
+
7
+ module Utcp
8
+ module Providers
9
+ # CLI provider for executing local commands (use with caution)
10
+ # tool.provider: { "provider_type":"cli", "command":["echo","hello ${name}"] }
11
+ class CliProvider < BaseProvider
12
+ def initialize(name:)
13
+ super(name: name, provider_type: "cli", auth: nil)
14
+ end
15
+
16
+ def discover_tools!
17
+ raise ProviderError, "CLI is an execution provider only"
18
+ end
19
+
20
+ def call_tool(tool, arguments = {}, &block)
21
+ p = tool.provider
22
+ cmd = p["command"]
23
+ raise ConfigError, "cli provider requires 'command' array" unless cmd.is_a?(Array) && !cmd.empty?
24
+ args = Utils::Subst.apply(arguments || {})
25
+
26
+ expanded = cmd.map do |part|
27
+ part.to_s.gsub(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/) { |m| args[$1] || ENV[$1] || m }
28
+ end
29
+
30
+ stdout, stderr, status = Open3.capture3(*expanded)
31
+ {
32
+ "ok" => status.success?,
33
+ "exit_code" => status.exitstatus,
34
+ "stdout" => stdout,
35
+ "stderr" => stderr
36
+ }
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,52 @@
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 "base_provider"
9
+
10
+ module Utcp
11
+ module Providers
12
+ # Simple GraphQL over HTTP
13
+ # tool.provider: { "provider_type":"graphql", "url":"https://...",
14
+ # "query":"query ($code:String!) { country(code:$code){name}}",
15
+ # "operationName": "...optional..." }
16
+ class GraphQLProvider < BaseProvider
17
+ def initialize(name:, auth: nil, headers: {})
18
+ super(name: name, provider_type: "graphql", auth: auth)
19
+ @headers = headers || {}
20
+ end
21
+
22
+ def discover_tools!
23
+ raise ProviderError, "GraphQL is an execution provider only"
24
+ end
25
+
26
+ def call_tool(tool, arguments = {}, &block)
27
+ p = tool.provider
28
+ url = Utils::Subst.apply(p["url"])
29
+ uri = URI(url)
30
+ body = {
31
+ "query" => Utils::Subst.apply(p["query"]),
32
+ "variables" => Utils::Subst.apply(arguments || {})
33
+ }
34
+ body["operationName"] = p["operationName"] if p["operationName"]
35
+
36
+ req = Net::HTTP::Post.new(uri)
37
+ headers = { "Content-Type" => "application/json" }.merge(@headers)
38
+ @auth&.apply_headers(headers)
39
+ headers.each { |k, v| req[k] = v }
40
+ req.body = JSON.dump(body)
41
+
42
+ http = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https")
43
+ begin
44
+ res = http.request(req)
45
+ JSON.parse(res.body) rescue res.body
46
+ ensure
47
+ http.finish if http.active?
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end