ruby-utcp 1.1.4 → 1.1.5
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 +4 -4
- data/README.md +25 -0
- data/lib/utcp/protocols/base.rb +6 -0
- data/lib/utcp/protocols/cli.rb +2 -3
- data/lib/utcp/protocols/file.rb +2 -3
- data/lib/utcp/protocols/grpc.rb +20 -3
- data/lib/utcp/protocols/http.rb +18 -5
- data/lib/utcp/protocols/mcp.rb +26 -9
- data/lib/utcp/protocols/tcp.rb +2 -2
- data/lib/utcp/protocols/text.rb +2 -3
- data/lib/utcp/protocols/udp.rb +2 -2
- data/lib/utcp/protocols/webrtc.rb +2 -2
- data/lib/utcp/protocols/websocket.rb +3 -0
- data/lib/utcp/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8e5c4728819e312335ad446c2b453ceb45eafc6440d7926fd4116fac91852d4f
|
|
4
|
+
data.tar.gz: 5902911212d47fe254285b3218e7b47338bedef182ec1561b5b778de8c354d99
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6631d90cb34d055462da1f31105380e5d61ba0b7bb73aaaf7494e5a7c5f826258e76b8cbb84bb7263ad73641866592e248fd94d47ef64d6b7ba4f7e0ed302a25
|
|
7
|
+
data.tar.gz: 56f38514be76d1e9dd0565084e7c1d6febfed61dc45f1a3ff27b9ff6849827b8bb184c255e2ef8b5e8d273015890af70ec006a8280eb4dc8cf4b3c8f5571f49b
|
data/README.md
CHANGED
|
@@ -125,6 +125,29 @@ Complete runnable/configuration examples are in [examples/README.md](examples/RE
|
|
|
125
125
|
|
|
126
126
|
Run `make` to start every available matching local server, execute its clients, and cleanly stop the servers. Missing optional gRPC/WebRTC backends are reported and skipped. `make full-demo` is the strict 12/12 target; `make standard-demo` always runs only the pairs that do not need native backends.
|
|
127
127
|
|
|
128
|
+
### Authentication by transport
|
|
129
|
+
|
|
130
|
+
| Transport | Supported `auth` | Authentication checks |
|
|
131
|
+
| --- | --- | --- |
|
|
132
|
+
| HTTP | API key in header/query/cookie, Basic, OAuth2 | Discovery and calls; strips credentials on cross-origin redirects |
|
|
133
|
+
| SSE | API key in header/query/cookie, Basic, OAuth2 | Discovery and streaming; streaming redirects are rejected |
|
|
134
|
+
| Streamable HTTP (`streamable_http`, alias `http_stream`) | API key in header/query/cookie, Basic, OAuth2 | Discovery and streaming; streaming redirects are rejected |
|
|
135
|
+
| WebSocket | API key in header/query/cookie, Basic, OAuth2 | Handshake; connections are separated by client and effective credentials |
|
|
136
|
+
| GraphQL | API key in header/query/cookie, Basic, OAuth2 | Introspection, queries/mutations and WebSocket subscriptions |
|
|
137
|
+
| gRPC | API key with `location: "header"` mapped to metadata, Basic, OAuth2 | Discovery, unary calls and server streams |
|
|
138
|
+
| MCP HTTP | API key in header/query/cookie, Basic, OAuth2 | Initialization, discovery and calls; sessions are separated by client, endpoint and credentials |
|
|
139
|
+
| MCP stdio | No built-in `auth` support | Rejects `auth` before starting a process; configure the server's own credentials through its environment |
|
|
140
|
+
| CLI | No built-in `auth` support | Rejects `auth`; the invoked command can use explicit `env_vars` |
|
|
141
|
+
| TCP | No built-in `auth` support | Rejects `auth` before socket I/O; application message authentication is caller-defined |
|
|
142
|
+
| UDP | No built-in `auth` support | Rejects `auth` before socket I/O; application message authentication is caller-defined |
|
|
143
|
+
| WebRTC | No built-in `auth` support | Rejects `auth` before creating a peer; signaling authentication is not implemented |
|
|
144
|
+
| Text | No transport `auth` | Rejects `auth`; `auth_tools` can configure tools converted from OpenAPI |
|
|
145
|
+
| File | No transport `auth` | Rejects `auth` before reading a file; `auth_tools` can configure tools converted from OpenAPI |
|
|
146
|
+
|
|
147
|
+
Unsupported auth configuration raises `AuthenticationError` on calls and makes manual registration fail. HTTP/WebSocket 401/403 and native gRPC UNAUTHENTICATED/PERMISSION_DENIED errors are reported as `AuthenticationError`. Invalid header/cookie API keys containing CR/LF are rejected before transport I/O. MCP session IDs are stripped on cross-origin redirects; changed auth or static server headers create a fresh, initialized session. Custom MCP session factories are responsible for their own authentication.
|
|
148
|
+
|
|
149
|
+
The default suite tests the auth matrix at transport boundaries and uses local HTTP/WebSocket servers for wire-level checks. Native gRPC checks additionally run with `UTCP_NATIVE_TESTS=1 bundle exec rake native TESTOPTS='--name /NativeGRPCTest/'`; this subset does not require loading the WebRTC backend.
|
|
150
|
+
|
|
128
151
|
### HTTP, SSE, and Streamable HTTP
|
|
129
152
|
|
|
130
153
|
HTTP templates support URL path parameters, query parameters, JSON or text bodies, input-to-header mapping, API keys, Basic auth, and OAuth2 client credentials.
|
|
@@ -147,6 +170,8 @@ HTTP templates support URL path parameters, query parameters, JSON or text bodie
|
|
|
147
170
|
|
|
148
171
|
Remote HTTP endpoints must use HTTPS. Plain HTTP is accepted only for loopback hosts, which keeps local development convenient. Redirect targets are checked again and credentials are removed on cross-origin redirects.
|
|
149
172
|
|
|
173
|
+
Cross-origin redirects remove explicit `Authorization`, `Proxy-Authorization`, and `Cookie` headers as well as headers supplied by the auth configuration. OAuth2 token requests follow redirects only within the same origin (scheme, host, and port), so client credentials cannot be forwarded to another origin. Cached OAuth2 tokens are keyed by the token URL, client ID, client secret, and scope; changing credentials requires a new token request.
|
|
174
|
+
|
|
150
175
|
An HTTP, text, or file manual may also contain an OpenAPI 3 or Swagger 2 document. It is converted into UTCP tools automatically.
|
|
151
176
|
|
|
152
177
|
SSE and Streamable HTTP reuse the same URL, header, body, and auth conventions. Their streaming forms expose ordinary Ruby enumerators:
|
data/lib/utcp/protocols/base.rb
CHANGED
|
@@ -24,6 +24,12 @@ module UTCP
|
|
|
24
24
|
|
|
25
25
|
private
|
|
26
26
|
|
|
27
|
+
def assert_no_auth!(template, context: template.call_template_type)
|
|
28
|
+
return unless template.auth
|
|
29
|
+
|
|
30
|
+
raise AuthenticationError, "#{context} does not support the auth field"
|
|
31
|
+
end
|
|
32
|
+
|
|
27
33
|
def manual_from_payload(template, payload, source: "protocol response")
|
|
28
34
|
data = if payload.is_a?(String)
|
|
29
35
|
Utils.parse_document(payload, source: source)
|
data/lib/utcp/protocols/cli.rb
CHANGED
|
@@ -43,9 +43,9 @@ module UTCP
|
|
|
43
43
|
private
|
|
44
44
|
|
|
45
45
|
def assert_template!(template)
|
|
46
|
-
|
|
46
|
+
raise ValidationError, "CLI protocol requires a CliCallTemplate" unless template.is_a?(CliCallTemplate)
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
assert_no_auth!(template)
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def execute(client, template, arguments)
|
|
@@ -183,4 +183,3 @@ module UTCP
|
|
|
183
183
|
end
|
|
184
184
|
CliCommunicationProtocol = CLIProtocol
|
|
185
185
|
end
|
|
186
|
-
|
data/lib/utcp/protocols/file.rb
CHANGED
|
@@ -34,9 +34,9 @@ module UTCP
|
|
|
34
34
|
private
|
|
35
35
|
|
|
36
36
|
def assert_template!(template)
|
|
37
|
-
|
|
37
|
+
raise ValidationError, "file protocol requires a FileCallTemplate" unless template.is_a?(FileCallTemplate)
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
assert_no_auth!(template)
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
def resolve_path(client, path)
|
|
@@ -49,4 +49,3 @@ module UTCP
|
|
|
49
49
|
end
|
|
50
50
|
FileCommunicationProtocol = FileProtocol
|
|
51
51
|
end
|
|
52
|
-
|
data/lib/utcp/protocols/grpc.rb
CHANGED
|
@@ -85,15 +85,21 @@ module UTCP
|
|
|
85
85
|
deadline: Time.now + timeout,
|
|
86
86
|
metadata: metadata
|
|
87
87
|
)
|
|
88
|
+
rescue GRPC::Unauthenticated, GRPC::PermissionDenied => error
|
|
89
|
+
raise AuthenticationError, "gRPC authentication failed: #{error.details}"
|
|
88
90
|
end
|
|
89
91
|
|
|
90
92
|
def server_stream(route, payload, timeout:, metadata: {})
|
|
93
|
+
return enum_for(__method__, route, payload, timeout: timeout, metadata: metadata) unless block_given?
|
|
94
|
+
|
|
91
95
|
@stub.server_streamer(
|
|
92
96
|
route, payload,
|
|
93
97
|
->(value) { value.to_s.b }, ->(bytes) { bytes },
|
|
94
98
|
deadline: Time.now + timeout,
|
|
95
99
|
metadata: metadata
|
|
96
|
-
)
|
|
100
|
+
).each { |response| yield response }
|
|
101
|
+
rescue GRPC::Unauthenticated, GRPC::PermissionDenied => error
|
|
102
|
+
raise AuthenticationError, "gRPC authentication failed: #{error.details}"
|
|
97
103
|
end
|
|
98
104
|
end
|
|
99
105
|
|
|
@@ -165,9 +171,20 @@ module UTCP
|
|
|
165
171
|
private
|
|
166
172
|
|
|
167
173
|
def assert_grpc_template!(template)
|
|
168
|
-
|
|
174
|
+
raise ValidationError, "gRPC protocol requires a GrpcCallTemplate" unless template.is_a?(GrpcCallTemplate)
|
|
169
175
|
|
|
170
|
-
|
|
176
|
+
case template.auth
|
|
177
|
+
when nil, BasicAuth, OAuth2Auth
|
|
178
|
+
nil
|
|
179
|
+
when ApiKeyAuth
|
|
180
|
+
unless template.auth.location == "header"
|
|
181
|
+
raise AuthenticationError, "gRPC API keys require location=header (request metadata)"
|
|
182
|
+
end
|
|
183
|
+
assert_header_safe!(template.auth.var_name, "API key name")
|
|
184
|
+
assert_header_safe!(template.auth.api_key, "API key value")
|
|
185
|
+
else
|
|
186
|
+
raise AuthenticationError, "Unsupported gRPC authentication type: #{template.auth.auth_type}"
|
|
187
|
+
end
|
|
171
188
|
end
|
|
172
189
|
|
|
173
190
|
def rpc_client(template)
|
data/lib/utcp/protocols/http.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "base64"
|
|
4
|
+
require "digest/sha2"
|
|
4
5
|
require "ipaddr"
|
|
5
6
|
require "json"
|
|
6
7
|
require "net/http"
|
|
@@ -41,6 +42,7 @@ module UTCP
|
|
|
41
42
|
|
|
42
43
|
class HTTPProtocol < CommunicationProtocol
|
|
43
44
|
REDIRECTS = [301, 302, 303, 307, 308].freeze
|
|
45
|
+
SENSITIVE_HEADERS = %w[Authorization Proxy-Authorization Cookie].freeze
|
|
44
46
|
REQUEST_CLASSES = {
|
|
45
47
|
"GET" => Net::HTTP::Get,
|
|
46
48
|
"POST" => Net::HTTP::Post,
|
|
@@ -183,6 +185,7 @@ module UTCP
|
|
|
183
185
|
case auth
|
|
184
186
|
when ApiKeyAuth
|
|
185
187
|
assert_header_safe!(auth.var_name, "API key name")
|
|
188
|
+
assert_header_safe!(auth.api_key, "API key value") unless auth.location == "query"
|
|
186
189
|
case auth.location
|
|
187
190
|
when "header"
|
|
188
191
|
headers[auth.var_name] = auth.api_key
|
|
@@ -207,7 +210,10 @@ module UTCP
|
|
|
207
210
|
|
|
208
211
|
def oauth_token(auth)
|
|
209
212
|
@oauth_mutex.synchronize do
|
|
210
|
-
|
|
213
|
+
# Include the full credentials without retaining a plaintext secret in the cache key.
|
|
214
|
+
cache_key = Digest::SHA256.hexdigest(JSON.generate([
|
|
215
|
+
auth.token_url, auth.client_id, auth.client_secret, auth.scope
|
|
216
|
+
]))
|
|
211
217
|
cached = @oauth_tokens[cache_key]
|
|
212
218
|
return cached[:token] if cached && cached[:expires_at] > Time.now.to_f + 5
|
|
213
219
|
|
|
@@ -222,7 +228,7 @@ module UTCP
|
|
|
222
228
|
"POST", token_uri,
|
|
223
229
|
headers: {}, cookies: {}, body: URI.encode_www_form(fields),
|
|
224
230
|
content_type: "application/x-www-form-urlencoded", timeout: @open_timeout,
|
|
225
|
-
sensitive_headers: ["Authorization"]
|
|
231
|
+
sensitive_headers: ["Authorization"], allow_cross_origin_redirects: false
|
|
226
232
|
)
|
|
227
233
|
data = JSON.parse(response.body)
|
|
228
234
|
token = data["access_token"]
|
|
@@ -239,7 +245,7 @@ module UTCP
|
|
|
239
245
|
end
|
|
240
246
|
|
|
241
247
|
def perform_request(method, uri, headers:, cookies:, body:, content_type:, timeout:,
|
|
242
|
-
sensitive_headers:, redirects: 0)
|
|
248
|
+
sensitive_headers:, redirects: 0, allow_cross_origin_redirects: true)
|
|
243
249
|
URLSecurity.validate!(uri.to_s, context: "HTTP request")
|
|
244
250
|
raise ToolCallError, "Too many HTTP redirects" if redirects > @max_redirects
|
|
245
251
|
|
|
@@ -271,7 +277,13 @@ module UTCP
|
|
|
271
277
|
next_headers = headers.dup
|
|
272
278
|
next_cookies = cookies.dup
|
|
273
279
|
unless URLSecurity.same_origin?(uri, target)
|
|
274
|
-
|
|
280
|
+
# Token requests carry credentials in the body, which header stripping cannot protect.
|
|
281
|
+
unless allow_cross_origin_redirects
|
|
282
|
+
raise SecurityError, "Cross-origin HTTP redirects are not allowed for this request"
|
|
283
|
+
end
|
|
284
|
+
(SENSITIVE_HEADERS + sensitive_headers).each do |name|
|
|
285
|
+
next_headers.delete_if { |key, _| key.casecmp?(name) }
|
|
286
|
+
end
|
|
275
287
|
next_cookies = {}
|
|
276
288
|
end
|
|
277
289
|
next_method = response.code.to_i == 303 || ([301, 302].include?(response.code.to_i) && method.to_s.upcase == "POST") ? "GET" : method
|
|
@@ -279,7 +291,8 @@ module UTCP
|
|
|
279
291
|
return perform_request(
|
|
280
292
|
next_method, target, headers: next_headers, cookies: next_cookies,
|
|
281
293
|
body: next_body, content_type: content_type, timeout: timeout,
|
|
282
|
-
sensitive_headers: sensitive_headers, redirects: redirects + 1
|
|
294
|
+
sensitive_headers: sensitive_headers, redirects: redirects + 1,
|
|
295
|
+
allow_cross_origin_redirects: allow_cross_origin_redirects
|
|
283
296
|
)
|
|
284
297
|
end
|
|
285
298
|
|
data/lib/utcp/protocols/mcp.rb
CHANGED
|
@@ -232,7 +232,6 @@ module UTCP
|
|
|
232
232
|
template.servers.each do |server_name, config|
|
|
233
233
|
begin
|
|
234
234
|
session = session_for(client, template, server_name, config)
|
|
235
|
-
initialize_session(session, template)
|
|
236
235
|
each_list_item(session, "tools/list", "tools") do |tool|
|
|
237
236
|
tools << Tool.new(
|
|
238
237
|
name: "#{server_name}.#{tool.fetch("name")}",
|
|
@@ -296,6 +295,7 @@ module UTCP
|
|
|
296
295
|
query = {}
|
|
297
296
|
cookies = {}
|
|
298
297
|
sensitive = apply_auth(template.auth, headers, query, cookies)
|
|
298
|
+
sensitive << "MCP-Session-Id"
|
|
299
299
|
if template.auth.is_a?(OAuth2Auth)
|
|
300
300
|
headers["Authorization"] = "Bearer #{oauth_token(template.auth)}"
|
|
301
301
|
sensitive << "Authorization"
|
|
@@ -326,15 +326,32 @@ module UTCP
|
|
|
326
326
|
end
|
|
327
327
|
|
|
328
328
|
def session_for(client, template, server_name, config)
|
|
329
|
-
|
|
329
|
+
http_transport = %w[http streamable_http sse].include?(config["transport"].to_s) || config["url"]
|
|
330
|
+
assert_no_auth!(template, context: "MCP stdio") unless http_transport || @session_factory
|
|
331
|
+
# A session belongs to the credentials and endpoint used to initialize it.
|
|
332
|
+
fingerprint = Digest::SHA256.hexdigest(JSON.generate([
|
|
333
|
+
config, template.auth&.to_h, template.protocol_version, template.timeout
|
|
334
|
+
]))
|
|
335
|
+
key = [client, template.name, server_name, fingerprint]
|
|
330
336
|
@sessions_mutex.synchronize do
|
|
331
|
-
@sessions[key]
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
337
|
+
return @sessions[key] if @sessions.key?(key)
|
|
338
|
+
|
|
339
|
+
snapshot = McpCallTemplate.from_h(Utils.deep_copy(template.to_h))
|
|
340
|
+
server_config = snapshot.servers.fetch(server_name)
|
|
341
|
+
session = if @session_factory
|
|
342
|
+
@session_factory.call(server_name, server_config, snapshot)
|
|
343
|
+
elsif http_transport
|
|
344
|
+
MCPHTTPSession.new(server_config, snapshot, self)
|
|
345
|
+
else
|
|
346
|
+
MCPStdioSession.new(server_config, snapshot.timeout)
|
|
347
|
+
end
|
|
348
|
+
begin
|
|
349
|
+
initialize_session(session, snapshot)
|
|
350
|
+
rescue StandardError
|
|
351
|
+
session.close
|
|
352
|
+
raise
|
|
353
|
+
end
|
|
354
|
+
@sessions[key] = session
|
|
338
355
|
end
|
|
339
356
|
end
|
|
340
357
|
|
data/lib/utcp/protocols/tcp.rb
CHANGED
|
@@ -31,9 +31,9 @@ module UTCP
|
|
|
31
31
|
private
|
|
32
32
|
|
|
33
33
|
def assert_template!(template)
|
|
34
|
-
|
|
34
|
+
raise ValidationError, "TCP protocol requires a TcpCallTemplate" unless template.is_a?(TcpCallTemplate)
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
assert_no_auth!(template)
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def exchange(template, message)
|
data/lib/utcp/protocols/text.rb
CHANGED
|
@@ -30,9 +30,9 @@ module UTCP
|
|
|
30
30
|
private
|
|
31
31
|
|
|
32
32
|
def assert_template!(template)
|
|
33
|
-
|
|
33
|
+
raise ValidationError, "text protocol requires a TextCallTemplate" unless template.is_a?(TextCallTemplate)
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
assert_no_auth!(template)
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def openapi?(data)
|
|
@@ -41,4 +41,3 @@ module UTCP
|
|
|
41
41
|
end
|
|
42
42
|
TextCommunicationProtocol = TextProtocol
|
|
43
43
|
end
|
|
44
|
-
|
data/lib/utcp/protocols/udp.rb
CHANGED
|
@@ -30,9 +30,9 @@ module UTCP
|
|
|
30
30
|
private
|
|
31
31
|
|
|
32
32
|
def assert_template!(template)
|
|
33
|
-
|
|
33
|
+
raise ValidationError, "UDP protocol requires a UdpCallTemplate" unless template.is_a?(UdpCallTemplate)
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
assert_no_auth!(template)
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def exchange(template, message, response_count:)
|
|
@@ -204,9 +204,9 @@ module UTCP
|
|
|
204
204
|
private
|
|
205
205
|
|
|
206
206
|
def assert_webrtc_template!(template)
|
|
207
|
-
|
|
207
|
+
raise ValidationError, "WebRTC protocol requires a WebRtcCallTemplate" unless template.is_a?(WebRtcCallTemplate)
|
|
208
208
|
|
|
209
|
-
|
|
209
|
+
assert_no_auth!(template)
|
|
210
210
|
end
|
|
211
211
|
|
|
212
212
|
def peer_for(client, template)
|
|
@@ -140,6 +140,9 @@ module UTCP
|
|
|
140
140
|
header_text = read_headers
|
|
141
141
|
lines = header_text.split("\r\n")
|
|
142
142
|
status = lines.shift.to_s.split[1].to_i
|
|
143
|
+
if status == 401 || status == 403
|
|
144
|
+
raise AuthenticationError, "WebSocket authentication failed with status #{status}"
|
|
145
|
+
end
|
|
143
146
|
raise ToolCallError, "WebSocket handshake failed with status #{status}" unless status == 101
|
|
144
147
|
|
|
145
148
|
response_headers = lines.each_with_object({}) do |line, result|
|
data/lib/utcp/version.rb
CHANGED