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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 265426eb4dd2ca952c81a23b71aeb8c1f0b1c90f8fbf247ec502279b0129ffba
4
- data.tar.gz: 79c0d4d21648777686b7caf3cc32f333cb7001bfcf8fa3f10c340195a706f449
3
+ metadata.gz: 8e5c4728819e312335ad446c2b453ceb45eafc6440d7926fd4116fac91852d4f
4
+ data.tar.gz: 5902911212d47fe254285b3218e7b47338bedef182ec1561b5b778de8c354d99
5
5
  SHA512:
6
- metadata.gz: 698ae3e9f0600fedeaa7adf2ae51c4e6872affed4a2da34454456c51f444a25d83ae59d6984d395b77f85ca36fa426731bcca6565cd0e7b1fc915473cb1caf3a
7
- data.tar.gz: 1a0ff70255dbd8b4ab2b7b323e638d726646423833144eed365d3fd9236908a038e0652c18548e9095ecbbfa87a87b2d01b5167dc8d93125918b09054326f21d
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:
@@ -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)
@@ -43,9 +43,9 @@ module UTCP
43
43
  private
44
44
 
45
45
  def assert_template!(template)
46
- return if template.is_a?(CliCallTemplate)
46
+ raise ValidationError, "CLI protocol requires a CliCallTemplate" unless template.is_a?(CliCallTemplate)
47
47
 
48
- raise ValidationError, "CLI protocol requires a CliCallTemplate"
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
-
@@ -34,9 +34,9 @@ module UTCP
34
34
  private
35
35
 
36
36
  def assert_template!(template)
37
- return if template.is_a?(FileCallTemplate)
37
+ raise ValidationError, "file protocol requires a FileCallTemplate" unless template.is_a?(FileCallTemplate)
38
38
 
39
- raise ValidationError, "file protocol requires a FileCallTemplate"
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
-
@@ -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
- return if template.is_a?(GrpcCallTemplate)
174
+ raise ValidationError, "gRPC protocol requires a GrpcCallTemplate" unless template.is_a?(GrpcCallTemplate)
169
175
 
170
- raise ValidationError, "gRPC protocol requires a GrpcCallTemplate"
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)
@@ -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
- cache_key = [auth.token_url, auth.client_id, auth.scope]
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
- sensitive_headers.each { |name| next_headers.delete_if { |key, _| key.casecmp?(name) } }
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
 
@@ -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
- key = [client, template.name, server_name]
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] ||= if @session_factory
332
- @session_factory.call(server_name, config, template)
333
- elsif %w[http streamable_http sse].include?(config["transport"].to_s) || config["url"]
334
- MCPHTTPSession.new(config, template, self)
335
- else
336
- MCPStdioSession.new(config, template.timeout)
337
- end
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
 
@@ -31,9 +31,9 @@ module UTCP
31
31
  private
32
32
 
33
33
  def assert_template!(template)
34
- return if template.is_a?(TcpCallTemplate)
34
+ raise ValidationError, "TCP protocol requires a TcpCallTemplate" unless template.is_a?(TcpCallTemplate)
35
35
 
36
- raise ValidationError, "TCP protocol requires a TcpCallTemplate"
36
+ assert_no_auth!(template)
37
37
  end
38
38
 
39
39
  def exchange(template, message)
@@ -30,9 +30,9 @@ module UTCP
30
30
  private
31
31
 
32
32
  def assert_template!(template)
33
- return if template.is_a?(TextCallTemplate)
33
+ raise ValidationError, "text protocol requires a TextCallTemplate" unless template.is_a?(TextCallTemplate)
34
34
 
35
- raise ValidationError, "text protocol requires a TextCallTemplate"
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
-
@@ -30,9 +30,9 @@ module UTCP
30
30
  private
31
31
 
32
32
  def assert_template!(template)
33
- return if template.is_a?(UdpCallTemplate)
33
+ raise ValidationError, "UDP protocol requires a UdpCallTemplate" unless template.is_a?(UdpCallTemplate)
34
34
 
35
- raise ValidationError, "UDP protocol requires a UdpCallTemplate"
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
- return if template.is_a?(WebRtcCallTemplate)
207
+ raise ValidationError, "WebRTC protocol requires a WebRtcCallTemplate" unless template.is_a?(WebRtcCallTemplate)
208
208
 
209
- raise ValidationError, "WebRTC protocol requires a WebRtcCallTemplate"
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
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module UTCP
4
- VERSION = "1.1.4"
4
+ VERSION = "1.1.5"
5
5
  end
6
6
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-utcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.4
4
+ version: 1.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - ruby-utcp contributors