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
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
|
data/lib/utcp/client.rb
ADDED
|
@@ -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
|
data/lib/utcp/errors.rb
ADDED
|
@@ -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
|