mcp 0.16.0 → 0.17.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.
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Client
5
+ module OAuth
6
+ # Pluggable OAuth client configuration handed to `MCP::Client::HTTP` via
7
+ # the `oauth:` keyword. Inspired by the OAuthClientProvider in the TypeScript SDK
8
+ # and httpx.Auth-based provider in the Python SDK.
9
+ #
10
+ # Required keyword arguments:
11
+ # - `client_metadata` - Hash sent to the authorization server's Dynamic Client
12
+ # Registration endpoint. Must include at minimum `redirect_uris`,
13
+ # `grant_types`, `response_types`, and `token_endpoint_auth_method`.
14
+ # - `redirect_uri` - String: the redirect URI used for the authorization
15
+ # request. Must be one of `redirect_uris` in `client_metadata`.
16
+ # - `redirect_handler` - Callable invoked with the fully-built authorization
17
+ # URL (a `URI`). Implementations typically open the user's browser.
18
+ # - `callback_handler` - Callable invoked after `redirect_handler`. Returns
19
+ # `[code, state]` where `code` is the authorization code and `state` is
20
+ # the `state` parameter received on the redirect URI.
21
+ #
22
+ # Optional keyword arguments:
23
+ # - `scope` - String of space-separated scopes to request when the server's
24
+ # `WWW-Authenticate` does not specify one.
25
+ # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
26
+ # `client_information`, and `save_client_information(info)`. Defaults to
27
+ # an `InMemoryStorage`.
28
+ class Provider
29
+ # Raised when `Provider#initialize` is called with a `redirect_uri` that
30
+ # is neither HTTPS nor a loopback `http://` URL, per the MCP
31
+ # authorization spec's Communication Security requirement.
32
+ class InsecureRedirectURIError < ArgumentError; end
33
+
34
+ # Raised when the `redirect_uri` argument is not listed in
35
+ # `client_metadata[:redirect_uris]` / `["redirect_uris"]`. Registering
36
+ # the URI with the authorization server but then sending a different
37
+ # one with the authorization request would be rejected by the AS at
38
+ # runtime; failing at construction surfaces the bug earlier.
39
+ class UnregisteredRedirectURIError < ArgumentError; end
40
+
41
+ attr_reader :client_metadata,
42
+ :redirect_uri,
43
+ :scope,
44
+ :storage,
45
+ :redirect_handler,
46
+ :callback_handler
47
+
48
+ def initialize(
49
+ client_metadata:,
50
+ redirect_uri:,
51
+ redirect_handler:,
52
+ callback_handler:,
53
+ scope: nil,
54
+ storage: nil
55
+ )
56
+ unless Discovery.secure_url?(redirect_uri)
57
+ raise InsecureRedirectURIError,
58
+ "redirect_uri #{redirect_uri.inspect} must use https or be a loopback http URL " \
59
+ "(localhost, 127.0.0.0/8, or ::1) per the MCP authorization Communication Security requirement."
60
+ end
61
+
62
+ registered = Array(client_metadata[:redirect_uris] || client_metadata["redirect_uris"])
63
+ unless registered.include?(redirect_uri)
64
+ raise UnregisteredRedirectURIError,
65
+ "redirect_uri #{redirect_uri.inspect} must be listed in client_metadata[:redirect_uris] " \
66
+ "(got #{registered.inspect}); otherwise the authorization server will reject the authorization request."
67
+ end
68
+
69
+ @client_metadata = client_metadata
70
+ @redirect_uri = redirect_uri
71
+ @redirect_handler = redirect_handler
72
+ @callback_handler = callback_handler
73
+ @scope = scope
74
+ @storage = storage || InMemoryStorage.new
75
+ end
76
+
77
+ def access_token
78
+ tokens&.dig("access_token") || tokens&.dig(:access_token)
79
+ end
80
+
81
+ def tokens
82
+ @storage.tokens
83
+ end
84
+
85
+ def save_tokens(tokens)
86
+ @storage.save_tokens(tokens)
87
+ end
88
+
89
+ def client_information
90
+ @storage.client_information
91
+ end
92
+
93
+ def save_client_information(info)
94
+ @storage.save_client_information(info)
95
+ end
96
+
97
+ def clear_tokens!
98
+ @storage.save_tokens(nil)
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "oauth/discovery"
4
+ require_relative "oauth/flow"
5
+ require_relative "oauth/in_memory_storage"
6
+ require_relative "oauth/pkce"
7
+ require_relative "oauth/provider"
8
+
9
+ module MCP
10
+ class Client
11
+ # OAuth client support for the MCP Authorization spec (PRM discovery,
12
+ # Authorization Server metadata discovery, Dynamic Client Registration,
13
+ # OAuth 2.1 Authorization Code + PKCE).
14
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
15
+ module OAuth
16
+ end
17
+ end
18
+ end
data/lib/mcp/client.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "client/oauth"
3
4
  require_relative "client/stdio"
4
5
  require_relative "client/http"
5
6
  require_relative "client/paginated_result"
@@ -16,6 +16,8 @@ module MCP
16
16
  class Server
17
17
  module Transports
18
18
  class StreamableHTTPTransport < Transport
19
+ class InvalidJsonError < StandardError; end
20
+
19
21
  SSE_HEADERS = {
20
22
  "Content-Type" => "text/event-stream",
21
23
  "Cache-Control" => "no-cache",
@@ -339,24 +341,51 @@ module MCP
339
341
  body_string = request.body.read
340
342
  session_id = extract_session_id(request)
341
343
 
342
- body = parse_request_body(body_string)
343
- return body unless body.is_a?(Hash) # Error response
344
+ begin
345
+ body = parse_request_body(body_string)
346
+ rescue InvalidJsonError
347
+ return invalid_json_response
348
+ end
344
349
 
345
- if body[:method] == "initialize"
346
- handle_initialization(body_string, body)
347
- else
350
+ # Streamable HTTP (2025-11-25) requires a single JSON-RPC message object per POST.
351
+ # Batched/array bodies are not supported; reject with `-32600` instead of falling through to
352
+ # a malformed Rack response.
353
+ unless body.is_a?(Hash)
354
+ return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object")
355
+ end
356
+
357
+ # The `MCP-Protocol-Version` header is only meaningful after negotiation, so on `initialize`
358
+ # the JSON-RPC body `params.protocolVersion` is authoritative and the header (if any) is ignored.
359
+ # This matches the TypeScript and Python SDKs.
360
+ unless initialize_request?(body)
348
361
  return missing_session_id_response if !@stateless && !session_id
349
362
 
350
- if notification?(body)
351
- dispatch_notification(body_string, session_id)
352
- handle_accepted
353
- elsif response?(body)
354
- return session_not_found_response if !@stateless && !session_exists?(session_id)
363
+ protocol_version_error = validate_protocol_version_header(request)
364
+ return protocol_version_error if protocol_version_error
365
+ end
355
366
 
356
- handle_response(body, session_id: session_id)
357
- else
358
- handle_regular_request(body_string, session_id, related_request_id: body[:id])
367
+ if initialize_request?(body)
368
+ if !@stateless && session_id
369
+ # An `initialize` request carrying an `Mcp-Session-Id` header is either a duplicate
370
+ # initialization attempt against a live session, or a retry against an unknown/expired
371
+ # one. In the live case, reject with `-32600` so the original session is not abandoned.
372
+ # In the unknown/expired case, return 404 so the client retries from scratch instead
373
+ # of silently inheriting a fresh session under the old ID.
374
+ return already_initialized_response(body[:id]) if session_active?(session_id)
375
+
376
+ return session_not_found_response
359
377
  end
378
+
379
+ handle_initialization(body_string, body)
380
+ elsif notification?(body)
381
+ dispatch_notification(body_string, session_id)
382
+ handle_accepted
383
+ elsif response?(body)
384
+ return session_not_found_response if !@stateless && !session_exists?(session_id)
385
+
386
+ handle_response(body, session_id: session_id)
387
+ else
388
+ handle_regular_request(body_string, session_id, related_request_id: body[:id])
360
389
  end
361
390
  rescue StandardError => e
362
391
  MCP.configuration.exception_reporter.call(e, { request: body_string })
@@ -377,6 +406,10 @@ module MCP
377
406
 
378
407
  error_response = validate_and_touch_session(session_id)
379
408
  return error_response if error_response
409
+
410
+ protocol_version_error = validate_protocol_version_header(request)
411
+ return protocol_version_error if protocol_version_error
412
+
380
413
  return session_already_connected_response if get_session_stream(session_id)
381
414
 
382
415
  setup_sse_stream(session_id)
@@ -386,6 +419,9 @@ module MCP
386
419
  success_response = [200, { "Content-Type" => "application/json" }, [{ success: true }.to_json]]
387
420
 
388
421
  if @stateless
422
+ protocol_version_error = validate_protocol_version_header(request)
423
+ return protocol_version_error if protocol_version_error
424
+
389
425
  # Stateless mode doesn't support sessions, so we can just return a success response
390
426
  return success_response
391
427
  end
@@ -393,6 +429,9 @@ module MCP
393
429
  return missing_session_id_response unless (session_id = request.env["HTTP_MCP_SESSION_ID"])
394
430
  return session_not_found_response unless session_exists?(session_id)
395
431
 
432
+ protocol_version_error = validate_protocol_version_header(request)
433
+ return protocol_version_error if protocol_version_error
434
+
396
435
  cleanup_session(session_id)
397
436
 
398
437
  success_response
@@ -492,9 +531,34 @@ module MCP
492
531
  def parse_request_body(body_string)
493
532
  JSON.parse(body_string, symbolize_names: true)
494
533
  rescue JSON::ParserError, TypeError
534
+ raise InvalidJsonError
535
+ end
536
+
537
+ def invalid_json_response
495
538
  [400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
496
539
  end
497
540
 
541
+ def initialize_request?(body)
542
+ body.is_a?(Hash) && body[:method] == Methods::INITIALIZE
543
+ end
544
+
545
+ def validate_protocol_version_header(request)
546
+ header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"]
547
+ return if header_value.nil?
548
+ return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value)
549
+
550
+ supported = MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.join(", ")
551
+ body = {
552
+ jsonrpc: "2.0",
553
+ id: nil,
554
+ error: {
555
+ code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
556
+ message: "Bad Request: Unsupported protocol version: #{header_value}. Supported versions: #{supported}",
557
+ },
558
+ }
559
+ [400, { "Content-Type" => "application/json" }, [body.to_json]]
560
+ end
561
+
498
562
  def notification?(body)
499
563
  !body[:id] && !!body[:method]
500
564
  end
@@ -560,6 +624,15 @@ module MCP
560
624
  @server.handle_json(body_string)
561
625
  end
562
626
 
627
+ # If `Server#init` produced an error response (e.g., malformed JSON-RPC envelope),
628
+ # `mark_initialized!` was never called. Discard the orphaned session and omit
629
+ # the `Mcp-Session-Id` header so the client retries from a clean state instead of
630
+ # reusing a never-initialized ID that would later look like a duplicate `initialize`.
631
+ if server_session && !server_session.initialized?
632
+ cleanup_session(session_id)
633
+ session_id = nil
634
+ end
635
+
563
636
  headers = {
564
637
  "Content-Type" => "application/json",
565
638
  }
@@ -694,6 +767,31 @@ module MCP
694
767
  @mutex.synchronize { @sessions.key?(session_id) }
695
768
  end
696
769
 
770
+ # Returns true iff a session exists and is not past its idle timeout. Expired sessions
771
+ # are evicted as a side effect so a live request never observes a zombie session that
772
+ # the reaper hasn't yet pruned. Does NOT update `last_active_at`; callers that are
773
+ # rejecting a request must not extend the session's lifetime.
774
+ def session_active?(session_id)
775
+ removed = nil
776
+ active = @mutex.synchronize do
777
+ next false unless (session = @sessions[session_id])
778
+
779
+ if session_expired?(session)
780
+ removed = cleanup_session_unsafe(session_id)
781
+ next false
782
+ end
783
+
784
+ true
785
+ end
786
+
787
+ if removed
788
+ close_stream_safely(removed[:get_sse_stream])
789
+ close_post_request_streams(removed)
790
+ end
791
+
792
+ active
793
+ end
794
+
697
795
  def method_not_allowed_response
698
796
  [405, { "Content-Type" => "application/json" }, [{ error: "Method not allowed" }.to_json]]
699
797
  end
@@ -706,6 +804,22 @@ module MCP
706
804
  [404, { "Content-Type" => "application/json" }, [{ error: "Session not found" }.to_json]]
707
805
  end
708
806
 
807
+ def already_initialized_response(request_id)
808
+ invalid_request_response("Invalid Request: Server already initialized", request_id: request_id)
809
+ end
810
+
811
+ def invalid_request_response(message, request_id: nil)
812
+ body = {
813
+ jsonrpc: "2.0",
814
+ id: request_id,
815
+ error: {
816
+ code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
817
+ message: message,
818
+ },
819
+ }
820
+ [400, { "Content-Type" => "application/json" }, [body.to_json]]
821
+ end
822
+
709
823
  def session_already_connected_response
710
824
  [
711
825
  409,
data/lib/mcp/server.rb CHANGED
@@ -497,6 +497,13 @@ module MCP
497
497
  end
498
498
 
499
499
  def init(params, session: nil)
500
+ # MCP spec: the initialization phase MUST be the first interaction between client and server.
501
+ # Reject duplicate `initialize` on an already-initialized session so the negotiated
502
+ # client identity and capabilities cannot be silently overwritten.
503
+ if session&.initialized?
504
+ raise RequestHandlerError.new("Invalid Request: Server already initialized", params, error_type: :invalid_request)
505
+ end
506
+
500
507
  if params
501
508
  if session
502
509
  session.store_client_info(client: params[:clientInfo], capabilities: params[:capabilities])
@@ -524,6 +531,8 @@ module MCP
524
531
  response_instructions = nil
525
532
  end
526
533
 
534
+ session&.mark_initialized!
535
+
527
536
  {
528
537
  protocolVersion: negotiated_version,
529
538
  capabilities: capabilities,
@@ -18,6 +18,19 @@ module MCP
18
18
  @logging_message_notification = nil
19
19
  @in_flight = {}
20
20
  @in_flight_mutex = Mutex.new
21
+ @initialized = false
22
+ end
23
+
24
+ # Whether `initialize` has already completed for this session.
25
+ def initialized?
26
+ @initialized
27
+ end
28
+
29
+ # Called by `Server#init` after a successful `initialize` response, so subsequent
30
+ # `initialize` requests on the same session can be rejected per MCP spec
31
+ # (the initialization phase MUST be the first interaction).
32
+ def mark_initialized!
33
+ @initialized = true
21
34
  end
22
35
 
23
36
  # Registers a `Cancellation` token for an in-flight request.
data/lib/mcp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MCP
4
- VERSION = "0.16.0"
4
+ VERSION = "0.17.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.16.0
4
+ version: 0.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Model Context Protocol
@@ -41,6 +41,12 @@ files:
41
41
  - lib/mcp/cancelled_error.rb
42
42
  - lib/mcp/client.rb
43
43
  - lib/mcp/client/http.rb
44
+ - lib/mcp/client/oauth.rb
45
+ - lib/mcp/client/oauth/discovery.rb
46
+ - lib/mcp/client/oauth/flow.rb
47
+ - lib/mcp/client/oauth/in_memory_storage.rb
48
+ - lib/mcp/client/oauth/pkce.rb
49
+ - lib/mcp/client/oauth/provider.rb
44
50
  - lib/mcp/client/paginated_result.rb
45
51
  - lib/mcp/client/stdio.rb
46
52
  - lib/mcp/client/tool.rb
@@ -81,7 +87,7 @@ licenses:
81
87
  - Apache-2.0
82
88
  metadata:
83
89
  allowed_push_host: https://rubygems.org
84
- changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.16.0
90
+ changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.17.0
85
91
  homepage_uri: https://ruby.sdk.modelcontextprotocol.io
86
92
  source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
87
93
  bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues