mcp 1.8.1 → 1.9.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.
data/lib/mcp/client.rb CHANGED
@@ -7,6 +7,8 @@ require_relative "client/oauth"
7
7
  require_relative "client/stdio"
8
8
  require_relative "client/http"
9
9
  require_relative "client/paginated_result"
10
+ require_relative "client/skill_errors"
11
+ require_relative "client/skills"
10
12
  require_relative "client/tool"
11
13
  require_relative "result_type"
12
14
 
@@ -114,6 +116,9 @@ module MCP
114
116
  STATE_ONLY_BACKOFF_INITIAL_SECONDS = 0.05
115
117
  STATE_ONLY_BACKOFF_CAP_SECONDS = 0.25
116
118
 
119
+ # The Skills extension methods (SEP-2640): `list_skills`, `get_skill`, `read_skill_resource`, `skills_capability`.
120
+ include Skills
121
+
117
122
  # Initializes a new MCP::Client instance.
118
123
  #
119
124
  # @param transport [Object] The transport object to use for communication with the server.
@@ -144,6 +149,8 @@ module MCP
144
149
  # a SEP-2322 `input_required` result.
145
150
  @input_required_handlers = {}
146
151
  @input_required_max_rounds = input_required_max_rounds
152
+ # Skills extension state (SEP-2640), bound to the connection by `connect`; see `Client::Skills`.
153
+ @skills_state = Skills::State.new
147
154
  end
148
155
 
149
156
  # The user may want to access additional transport-specific methods/attributes
@@ -217,7 +224,15 @@ module MCP
217
224
 
218
225
  effective_mode = resolve_connect_mode(mode, protocol_version)
219
226
 
220
- if effective_mode == :legacy
227
+ # Skills state (SEP-2640) follows the connection: a handshake about to run starts a new generation, its
228
+ # result is recorded only if no newer handshake started meanwhile, and a failure clears readiness under
229
+ # the same condition, so a stale completion or failure changes nothing. The bundled transports answer
230
+ # a repeated `connect` with their cached handshake result without any I/O, so when the transport is
231
+ # connected and Skills is ready nothing is reset: the latches of a live connection stay put.
232
+ generation = @skills_state.begin_handshake unless skills_ready_on_connected_transport?
233
+ completed = false
234
+
235
+ result = if effective_mode == :legacy
221
236
  transport.connect(
222
237
  client_info: client_info,
223
238
  protocol_version: protocol_version,
@@ -231,6 +246,11 @@ module MCP
231
246
  mode: effective_mode,
232
247
  )
233
248
  end
249
+ completed = true
250
+ @skills_state.handshake_completed(generation, result) if generation
251
+ result
252
+ ensure
253
+ @skills_state.handshake_failed(generation) if generation && !completed
234
254
  end
235
255
 
236
256
  # Sends `server/discover` (MCP 2026-07-28, SEP-2575): sessionless capability discovery
@@ -295,10 +315,14 @@ module MCP
295
315
 
296
316
  Tool.new(
297
317
  name: tool["name"],
318
+ title: tool["title"],
298
319
  description: tool["description"],
299
320
  input_schema: tool["inputSchema"],
300
321
  output_schema: tool["outputSchema"],
301
322
  annotations: tool["annotations"],
323
+ icons: tool["icons"],
324
+ execution: tool["execution"],
325
+ meta: tool["_meta"],
302
326
  )
303
327
  end
304
328
 
@@ -673,10 +697,19 @@ module MCP
673
697
  scan = McpParamHeaders.scan(tool["inputSchema"])
674
698
  return false if scan[:valid]
675
699
 
676
- warn("MCP::Client: excluding tool #{tool["name"].inspect} from tools/list: #{scan[:reason]}")
700
+ name = tool["name"]
701
+ # Both pieces are the server's: `dump` keeps control characters and bidirectional overrides out of the log line.
702
+ warn("MCP::Client: excluding tool #{(name.is_a?(String) ? name : name.inspect).dump} from tools/list: #{scan[:reason].dump}")
677
703
  true
678
704
  end
679
705
 
706
+ # Whether a `connect` would only hand back the transport's cached handshake: the transport reports itself
707
+ # connected and Skills already recorded a handshake of this connection. A transport without connection
708
+ # state always starts a new generation.
709
+ def skills_ready_on_connected_transport?
710
+ transport.respond_to?(:connected?) && transport.connected? && @skills_state.ready?
711
+ end
712
+
680
713
  # Resolves the effective SEP-2575 lifecycle mode for `connect`:
681
714
  #
682
715
  # - An explicit `protocol_version` from a legacy generation pins the legacy handshake without a probe,
data/lib/mcp/methods.rb CHANGED
@@ -22,6 +22,11 @@ module MCP
22
22
  RESOURCES_SUBSCRIBE = "resources/subscribe"
23
23
  RESOURCES_UNSUBSCRIBE = "resources/unsubscribe"
24
24
 
25
+ # Skills extension (SEP-2640): registered only when a server configures skills,
26
+ # so an unconfigured server answers `-32601` rather than a capability error.
27
+ SKILLS_LIST = "skills/list"
28
+ SKILLS_GET = "skills/get"
29
+
25
30
  TOOLS_CALL = "tools/call"
26
31
  TOOLS_LIST = "tools/list"
27
32
 
@@ -476,6 +476,9 @@ module MCP
476
476
  # with a `StandardError` does not stop the teardown below: `join` re-raises the exception a thread died with,
477
477
  # which is not this method's to raise. A reaper still alive after the wait is killed as before, leaving
478
478
  # the streams of that reap it had not closed yet as they were.
479
+ # A `close` running on the reaper thread itself, reached from a stream's `close` or from the exception reporter
480
+ # during a reap, neither joins nor kills it: the join would fail on its own thread and the kill would end
481
+ # this teardown with the reap that called it. The reaper leaves its loop on `@closing` once that reap returns.
479
482
  def close
480
483
  reaper = @mutex.synchronize do
481
484
  @closing = true
@@ -483,13 +486,15 @@ module MCP
483
486
  @reaper_thread
484
487
  end
485
488
 
486
- begin
487
- reaper&.join(REAPER_JOIN_TIMEOUT)
488
- rescue StandardError
489
- # The reaper is gone either way; the exception that ended it is not this method's to report.
490
- end
489
+ if reaper && !reaper.equal?(Thread.current)
490
+ begin
491
+ reaper.join(REAPER_JOIN_TIMEOUT)
492
+ rescue StandardError
493
+ # The reaper is gone either way; the exception that ended it is not this method's to report.
494
+ end
491
495
 
492
- reaper.kill if reaper&.alive?
496
+ reaper.kill if reaper.alive?
497
+ end
493
498
  @reaper_thread = nil
494
499
 
495
500
  teardown_listen_subscriptions
@@ -521,12 +526,19 @@ module MCP
521
526
  # a per-request sink for its ephemeral session and flushes it as SSE frames ahead of the final response.
522
527
  # Checked before the stateless guard, since modern requests are served in stateless deployments too.
523
528
  # The sink is bounded; past `MAX_MODERN_REQUEST_NOTIFICATIONS` the notification is dropped as
524
- # non-delivery (`false`), never handed to the legacy paths below.
529
+ # non-delivery (`false`), never handed to the legacy paths below. It holds each notification as JSON,
530
+ # generated here so that one that cannot be generated is dropped on its own instead of failing
531
+ # the whole response when the sink is flushed.
525
532
  sink = @mutex.synchronize { session_id && @modern_request_sinks[session_id] }
526
533
  if sink
527
534
  return false if sink.size >= MAX_MODERN_REQUEST_NOTIFICATIONS
528
535
 
529
- sink << notification
536
+ begin
537
+ sink << notification.to_json
538
+ rescue JSON::GeneratorError => e
539
+ MCP.configuration.exception_reporter.call(e, { session_id: session_id, error: "Failed to generate notification" })
540
+ return false
541
+ end
530
542
  return true
531
543
  end
532
544
 
@@ -931,16 +943,26 @@ module MCP
931
943
  # `nil` covers notifications and cancellation-suppressed responses; ack with 202 like the legacy notification path.
932
944
  return handle_accepted if response.nil?
933
945
 
946
+ # A response that cannot be generated answers its request with Internal error, as `JsonRpcHandler.handle_json`
947
+ # does on the legacy paths, instead of failing the whole exchange with a 500.
948
+ response_json = begin
949
+ response.to_json
950
+ rescue JSON::GeneratorError => e
951
+ MCP.configuration.exception_reporter.call(e, { error: "Failed to generate response" })
952
+ response = JsonRpcHandler.encodable_response(response)
953
+ response.to_json
954
+ end
955
+
934
956
  # Notifications a handler emitted during the request ride the request's own response stream as SSE frames ahead of
935
957
  # the final response (SEP-2575); a request that emitted none keeps the single JSON exchange and its HTTP status ladder.
936
958
  # Delivery is buffered: frames flush after the handler returns, preserving order but not real-time interleaving
937
959
  # (the TypeScript and Python SDKs stream live), and the sink grows with the handler's notification count.
938
960
  # Live streaming can follow without changing the wire shape.
939
961
  if notifications.empty?
940
- [modern_http_status(response), { "content-type" => "application/json" }, [response.to_json]]
962
+ [modern_http_status(response), { "content-type" => "application/json" }, [response_json]]
941
963
  else
942
- frames = notifications + [response]
943
- sse_body = frames.map { |frame| "event: message\ndata: #{frame.to_json}\n\n" }.join
964
+ frames = notifications + [response_json]
965
+ sse_body = frames.map { |frame| "event: message\ndata: #{frame}\n\n" }.join
944
966
  [200, SSE_HEADERS.dup, [sse_body]]
945
967
  end
946
968
  rescue StandardError => e
@@ -958,7 +980,7 @@ module MCP
958
980
  # converge on enforcement.
959
981
  def validate_modern_headers(request, body, header_version)
960
982
  params = body[:params]
961
- meta_version = params.is_a?(Hash) ? params.dig(:_meta, :"io.modelcontextprotocol/protocolVersion") : nil
983
+ meta_version = params.is_a?(Hash) && params[:_meta].is_a?(Hash) ? params.dig(:_meta, :"io.modelcontextprotocol/protocolVersion") : nil
962
984
  if meta_version && meta_version != header_version
963
985
  return header_mismatch_response(
964
986
  "MCP-Protocol-Version header value '#{header_version}' does not match body value '#{meta_version}'",
@@ -1034,7 +1056,7 @@ module MCP
1034
1056
 
1035
1057
  begin
1036
1058
  if RequestEnvelope.modern?(params)
1037
- RequestEnvelope.parse!(params, request: params)
1059
+ envelope = RequestEnvelope.parse!(params, request: params)
1038
1060
  else
1039
1061
  return invalid_request_response("Invalid Request: modern requests require the SEP-2575 `_meta` envelope")
1040
1062
  end
@@ -1074,7 +1096,7 @@ module MCP
1074
1096
  return too_many_listen_subscriptions_response(request_id)
1075
1097
  end
1076
1098
 
1077
- honored = honored_filter(filter)
1099
+ honored = honored_filter(filter, meta: params[:_meta], envelope: envelope, auth_info: auth_info)
1078
1100
 
1079
1101
  # The acknowledgement echoes the honored filter, whose resource URIs are client-supplied strings,
1080
1102
  # so it is encoded before the stream is committed to: a URI that cannot be written as JSON is
@@ -1321,8 +1343,9 @@ module MCP
1321
1343
 
1322
1344
  # Per SEP-2575, the server MUST NOT send notification types the client has not requested,
1323
1345
  # and the acknowledgement only includes types the server actually supports
1324
- # (derived from its declared capabilities).
1325
- def honored_filter(filter)
1346
+ # (derived from its declared capabilities). Resource URIs are further narrowed to those
1347
+ # the server's `resources_subscribe_handler` accepts; the field is omitted when none remain.
1348
+ def honored_filter(filter, meta: nil, envelope: nil, auth_info: nil)
1326
1349
  capabilities = @server.capabilities
1327
1350
  honored = {}
1328
1351
  honored[:toolsListChanged] = true if filter[:toolsListChanged] && capability_flag?(capabilities, :tools, :listChanged)
@@ -1331,7 +1354,8 @@ module MCP
1331
1354
 
1332
1355
  subscriptions = filter[:resourceSubscriptions]
1333
1356
  if capability_flag?(capabilities, :resources, :subscribe) && subscriptions.is_a?(Array) && !subscriptions.empty?
1334
- honored[:resourceSubscriptions] = subscriptions.uniq
1357
+ uris = @server.subscribable_resource_uris(subscriptions, meta: meta, envelope: envelope, auth_info: auth_info)
1358
+ honored[:resourceSubscriptions] = uris unless uris.empty?
1335
1359
  end
1336
1360
 
1337
1361
  honored
@@ -1875,7 +1899,7 @@ module MCP
1875
1899
  end
1876
1900
 
1877
1901
  def validate_accept_header(request, required_types)
1878
- accept_header = request.env["HTTP_ACCEPT"]
1902
+ accept_header = header_bytes(request, "HTTP_ACCEPT")
1879
1903
  return not_acceptable_response(required_types) unless accept_header
1880
1904
 
1881
1905
  accepted_types = parse_accept_header(accept_header)
@@ -1894,7 +1918,7 @@ module MCP
1894
1918
  end
1895
1919
 
1896
1920
  def validate_content_type(request)
1897
- content_type = request.env["CONTENT_TYPE"]
1921
+ content_type = header_bytes(request, "CONTENT_TYPE")
1898
1922
  media_type = content_type&.split(";")&.first&.strip&.downcase
1899
1923
  return if media_type == "application/json"
1900
1924
 
@@ -2404,10 +2428,18 @@ module MCP
2404
2428
  validate_host(request) || validate_origin(request)
2405
2429
  end
2406
2430
 
2431
+ # A header value as bytes. Rack servers hand header values over as ASCII-8BIT, where every byte is valid, but a framework
2432
+ # or a test request can tag them UTF-8, and then `downcase`, `split`, and regexps raise on an invalid byte sequence
2433
+ # before any rescue applies. Reading the value as bytes puts both on the same footing: a malformed value fails its comparison
2434
+ # and gets the rejection that comparison produces, instead of failing the request.
2435
+ def header_bytes(request, name)
2436
+ request.env[name]&.b
2437
+ end
2438
+
2407
2439
  # Rejects a rebound `Host` (e.g. `evil.example.com` re-pointed at 127.0.0.1).
2408
2440
  # A request without a `Host` header (e.g. HTTP/1.0) is allowed; the rebinding vector this guards against always carries one.
2409
2441
  def validate_host(request)
2410
- host = request.env["HTTP_HOST"]
2442
+ host = header_bytes(request, "HTTP_HOST")
2411
2443
  return if host.nil?
2412
2444
 
2413
2445
  # An `allowed_hosts:` entry matches either the bare host name (any port)
@@ -2422,7 +2454,7 @@ module MCP
2422
2454
  # A request without an `Origin` header (typical for non-browser MCP clients) is allowed. A browser cross-origin request is
2423
2455
  # rejected unless the origin is same-origin or explicitly allow-listed via `allowed_origins:`.
2424
2456
  def validate_origin(request)
2425
- origin = request.env["HTTP_ORIGIN"]
2457
+ origin = header_bytes(request, "HTTP_ORIGIN")
2426
2458
  return if origin.nil?
2427
2459
  return if same_origin?(origin, request)
2428
2460
  return if @allowed_origins.include?(origin.downcase)
@@ -2443,7 +2475,7 @@ module MCP
2443
2475
  # but the `Origin`'s scheme is used to drop a redundant default port (`:80` for http, `:443` for https) from
2444
2476
  # both sides so `http://example.com` matches `Host: example.com:80`. Comparison is case-insensitive.
2445
2477
  def same_origin?(origin, request)
2446
- host = request.env["HTTP_HOST"]
2478
+ host = header_bytes(request, "HTTP_HOST")
2447
2479
  return false if host.nil?
2448
2480
 
2449
2481
  normalized = origin.downcase