model-context-protocol-rb 0.5.1 → 0.7.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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -1
  3. data/README.md +181 -950
  4. data/lib/model_context_protocol/rspec/helpers.rb +54 -0
  5. data/lib/model_context_protocol/rspec/matchers/be_mcp_error_response.rb +123 -0
  6. data/lib/model_context_protocol/rspec/matchers/be_valid_mcp_class.rb +103 -0
  7. data/lib/model_context_protocol/rspec/matchers/be_valid_mcp_prompt_response.rb +126 -0
  8. data/lib/model_context_protocol/rspec/matchers/be_valid_mcp_resource_response.rb +121 -0
  9. data/lib/model_context_protocol/rspec/matchers/be_valid_mcp_tool_response.rb +135 -0
  10. data/lib/model_context_protocol/rspec/matchers/have_audio_content.rb +109 -0
  11. data/lib/model_context_protocol/rspec/matchers/have_embedded_resource_content.rb +150 -0
  12. data/lib/model_context_protocol/rspec/matchers/have_image_content.rb +109 -0
  13. data/lib/model_context_protocol/rspec/matchers/have_message_count.rb +87 -0
  14. data/lib/model_context_protocol/rspec/matchers/have_message_with_role.rb +152 -0
  15. data/lib/model_context_protocol/rspec/matchers/have_resource_annotations.rb +135 -0
  16. data/lib/model_context_protocol/rspec/matchers/have_resource_blob.rb +108 -0
  17. data/lib/model_context_protocol/rspec/matchers/have_resource_link_content.rb +138 -0
  18. data/lib/model_context_protocol/rspec/matchers/have_resource_mime_type.rb +103 -0
  19. data/lib/model_context_protocol/rspec/matchers/have_resource_text.rb +112 -0
  20. data/lib/model_context_protocol/rspec/matchers/have_structured_content.rb +88 -0
  21. data/lib/model_context_protocol/rspec/matchers/have_text_content.rb +113 -0
  22. data/lib/model_context_protocol/rspec/matchers.rb +31 -0
  23. data/lib/model_context_protocol/rspec.rb +23 -0
  24. data/lib/model_context_protocol/server/cancellable.rb +5 -5
  25. data/lib/model_context_protocol/server/{mcp_logger.rb → client_logger.rb} +8 -11
  26. data/lib/model_context_protocol/server/configuration.rb +196 -109
  27. data/lib/model_context_protocol/server/content_helpers.rb +1 -1
  28. data/lib/model_context_protocol/server/global_config/server_logging.rb +78 -0
  29. data/lib/model_context_protocol/server/progressable.rb +43 -21
  30. data/lib/model_context_protocol/server/prompt.rb +12 -21
  31. data/lib/model_context_protocol/server/redis_client_proxy.rb +2 -14
  32. data/lib/model_context_protocol/server/redis_config.rb +5 -7
  33. data/lib/model_context_protocol/server/redis_pool_manager.rb +11 -14
  34. data/lib/model_context_protocol/server/registry.rb +8 -0
  35. data/lib/model_context_protocol/server/resource.rb +7 -4
  36. data/lib/model_context_protocol/server/router.rb +285 -9
  37. data/lib/model_context_protocol/server/server_logger.rb +31 -0
  38. data/lib/model_context_protocol/server/stdio_configuration.rb +114 -0
  39. data/lib/model_context_protocol/server/stdio_transport/request_store.rb +12 -53
  40. data/lib/model_context_protocol/server/stdio_transport.rb +18 -12
  41. data/lib/model_context_protocol/server/streamable_http_configuration.rb +218 -0
  42. data/lib/model_context_protocol/server/streamable_http_transport/event_counter.rb +0 -13
  43. data/lib/model_context_protocol/server/streamable_http_transport/message_poller.rb +9 -9
  44. data/lib/model_context_protocol/server/streamable_http_transport/notification_queue.rb +0 -41
  45. data/lib/model_context_protocol/server/streamable_http_transport/request_store.rb +21 -124
  46. data/lib/model_context_protocol/server/streamable_http_transport/server_request_store.rb +167 -0
  47. data/lib/model_context_protocol/server/streamable_http_transport/session_message_queue.rb +0 -58
  48. data/lib/model_context_protocol/server/streamable_http_transport/session_store.rb +17 -31
  49. data/lib/model_context_protocol/server/streamable_http_transport/stream_registry.rb +0 -34
  50. data/lib/model_context_protocol/server/streamable_http_transport.rb +589 -215
  51. data/lib/model_context_protocol/server/tool.rb +73 -6
  52. data/lib/model_context_protocol/server.rb +204 -261
  53. data/lib/model_context_protocol/version.rb +1 -1
  54. data/lib/model_context_protocol.rb +4 -1
  55. data/lib/puma/plugin/mcp.rb +39 -0
  56. data/tasks/mcp.rake +26 -0
  57. data/tasks/templates/dev-http-puma.erb +251 -0
  58. data/tasks/templates/dev-http.erb +166 -184
  59. data/tasks/templates/dev.erb +29 -7
  60. metadata +33 -6
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ModelContextProtocol
4
+ module RSpec
5
+ module Matchers
6
+ # Matcher that validates a tool response contains specific text content.
7
+ #
8
+ # @example With exact string match
9
+ # expect(response).to have_text_content("Hello, World!")
10
+ #
11
+ # @example With regex match
12
+ # expect(response).to have_text_content(/doubled is \d+/)
13
+ #
14
+ def have_text_content(expected_text)
15
+ HaveTextContent.new(expected_text)
16
+ end
17
+
18
+ class HaveTextContent
19
+ def initialize(expected_text)
20
+ @expected_text = expected_text
21
+ @failure_reasons = []
22
+ end
23
+
24
+ def matches?(actual)
25
+ @actual = actual
26
+ @failure_reasons = []
27
+ @serialized = serialize_response(actual)
28
+
29
+ return false if @serialized.nil?
30
+
31
+ validate_has_content &&
32
+ validate_has_text_content
33
+ end
34
+
35
+ def failure_message
36
+ "expected response to have text content matching #{@expected_text.inspect}, but:\n" +
37
+ @failure_reasons.map { |reason| " - #{reason}" }.join("\n")
38
+ end
39
+
40
+ def failure_message_when_negated
41
+ "expected response not to have text content matching #{@expected_text.inspect}, but it did"
42
+ end
43
+
44
+ def description
45
+ "have text content matching #{@expected_text.inspect}"
46
+ end
47
+
48
+ private
49
+
50
+ def serialize_response(response)
51
+ if response.respond_to?(:serialized)
52
+ response.serialized
53
+ elsif response.is_a?(Hash)
54
+ response
55
+ else
56
+ @failure_reasons << "response must respond to :serialized or be a Hash"
57
+ nil
58
+ end
59
+ end
60
+
61
+ def validate_has_content
62
+ @content = @serialized[:content] || @serialized["content"]
63
+
64
+ unless @content
65
+ @failure_reasons << "response does not have :content key"
66
+ return false
67
+ end
68
+
69
+ unless @content.is_a?(Array)
70
+ @failure_reasons << "content must be an Array"
71
+ return false
72
+ end
73
+
74
+ true
75
+ end
76
+
77
+ def validate_has_text_content
78
+ text_items = @content.select do |item|
79
+ type = item[:type] || item["type"]
80
+ type == "text"
81
+ end
82
+
83
+ if text_items.empty?
84
+ @failure_reasons << "no text content found in response"
85
+ return false
86
+ end
87
+
88
+ matching_item = text_items.find do |item|
89
+ text = item[:text] || item["text"]
90
+ text_matches?(text)
91
+ end
92
+
93
+ unless matching_item
94
+ actual_texts = text_items.map { |item| item[:text] || item["text"] }
95
+ @failure_reasons << "no text content matched, found: #{actual_texts.inspect}"
96
+ return false
97
+ end
98
+
99
+ true
100
+ end
101
+
102
+ def text_matches?(text)
103
+ case @expected_text
104
+ when Regexp
105
+ @expected_text.match?(text)
106
+ else
107
+ text.include?(@expected_text.to_s)
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,31 @@
1
+ require_relative "matchers/be_valid_mcp_class"
2
+
3
+ # Tool response matchers
4
+ require_relative "matchers/be_valid_mcp_tool_response"
5
+ require_relative "matchers/have_structured_content"
6
+ require_relative "matchers/have_text_content"
7
+ require_relative "matchers/have_image_content"
8
+ require_relative "matchers/have_audio_content"
9
+ require_relative "matchers/have_embedded_resource_content"
10
+ require_relative "matchers/have_resource_link_content"
11
+ require_relative "matchers/be_mcp_error_response"
12
+
13
+ # Prompt response matchers
14
+ require_relative "matchers/be_valid_mcp_prompt_response"
15
+ require_relative "matchers/have_message_with_role"
16
+ require_relative "matchers/have_message_count"
17
+
18
+ # Resource response matchers
19
+ require_relative "matchers/be_valid_mcp_resource_response"
20
+ require_relative "matchers/have_resource_text"
21
+ require_relative "matchers/have_resource_blob"
22
+ require_relative "matchers/have_resource_mime_type"
23
+ require_relative "matchers/have_resource_annotations"
24
+
25
+ module ModelContextProtocol
26
+ module RSpec
27
+ module Matchers
28
+ # Include all matchers when this module is included
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,23 @@
1
+ require "model_context_protocol"
2
+ require_relative "rspec/matchers"
3
+ require_relative "rspec/helpers"
4
+
5
+ module ModelContextProtocol
6
+ module RSpec
7
+ # Convenience method to configure RSpec with MCP matchers and helpers.
8
+ #
9
+ # @example
10
+ # # In spec_helper.rb
11
+ # require "model_context_protocol/rspec"
12
+ # ModelContextProtocol::RSpec.configure!
13
+ #
14
+ # @return [void]
15
+ def self.configure!
16
+ ::RSpec.configure do |config|
17
+ config.include ModelContextProtocol::RSpec::Matchers
18
+ config.include ModelContextProtocol::RSpec::Helpers, type: :mcp
19
+ config.include ModelContextProtocol::RSpec::Helpers, file_path: %r{spec/mcp/}
20
+ end
21
+ end
22
+ end
23
+ end
@@ -24,8 +24,8 @@ module ModelContextProtocol
24
24
  executing_thread = Concurrent::AtomicReference.new(nil)
25
25
 
26
26
  timer_task = Concurrent::TimerTask.new(execution_interval: interval) do
27
- if context && context[:request_store] && context[:request_id]
28
- if context[:request_store].cancelled?(context[:request_id])
27
+ if context && context[:request_store] && context[:jsonrpc_request_id]
28
+ if context[:request_store].cancelled?(context[:jsonrpc_request_id])
29
29
  thread = executing_thread.get
30
30
  thread&.raise(CancellationError, "Request was cancelled") if thread&.alive?
31
31
  end
@@ -35,9 +35,9 @@ module ModelContextProtocol
35
35
  begin
36
36
  executing_thread.set(Thread.current)
37
37
 
38
- if context && context[:request_store] && context[:request_id]
39
- if context[:request_store].cancelled?(context[:request_id])
40
- raise CancellationError, "Request #{context[:request_id]} was cancelled"
38
+ if context && context[:request_store] && context[:jsonrpc_request_id]
39
+ if context[:request_store].cancelled?(context[:jsonrpc_request_id])
40
+ raise CancellationError, "Request #{context[:jsonrpc_request_id]} was cancelled"
41
41
  end
42
42
  end
43
43
 
@@ -3,11 +3,13 @@ require "forwardable"
3
3
  require "json"
4
4
 
5
5
  module ModelContextProtocol
6
- class Server::MCPLogger
6
+ class Server::ClientLogger
7
7
  extend Forwardable
8
8
 
9
9
  def_delegators :@internal_logger, :datetime_format=, :formatter=, :progname, :progname=
10
10
 
11
+ VALID_LOG_LEVELS = %w[debug info notice warning error critical alert emergency].freeze
12
+
11
13
  LEVEL_MAP = {
12
14
  "debug" => Logger::DEBUG,
13
15
  "info" => Logger::INFO,
@@ -28,12 +30,11 @@ module ModelContextProtocol
28
30
  Logger::UNKNOWN => "emergency"
29
31
  }.freeze
30
32
 
31
- attr_accessor :transport
32
- attr_reader :logger_name, :enabled
33
+ attr_reader :transport
34
+ attr_reader :logger_name
33
35
 
34
- def initialize(logger_name: "server", level: "info", enabled: true)
36
+ def initialize(logger_name: "server", level: "info")
35
37
  @logger_name = logger_name
36
- @enabled = enabled
37
38
  @internal_logger = Logger.new(nil)
38
39
  @internal_logger.level = LEVEL_MAP[level] || Logger::INFO
39
40
  @transport = nil
@@ -42,13 +43,11 @@ module ModelContextProtocol
42
43
 
43
44
  %i[debug info warn error fatal unknown].each do |severity|
44
45
  define_method(severity) do |message = nil, **data, &block|
45
- return true unless @enabled
46
46
  add(Logger.const_get(severity.to_s.upcase), message, data, &block)
47
47
  end
48
48
  end
49
49
 
50
50
  def add(severity, message = nil, data = {}, &block)
51
- return true unless @enabled
52
51
  return true if severity < @internal_logger.level
53
52
 
54
53
  message = block.call if message.nil? && block_given?
@@ -70,14 +69,12 @@ module ModelContextProtocol
70
69
 
71
70
  def connect_transport(transport)
72
71
  @transport = transport
73
- flush_queued_messages if @enabled
72
+ flush_queued_messages
74
73
  end
75
74
 
76
75
  private
77
76
 
78
77
  def send_notification(severity, message, data)
79
- return unless @enabled
80
-
81
78
  notification_params = {
82
79
  level: REVERSE_LEVEL_MAP[severity] || "info",
83
80
  logger: @logger_name,
@@ -99,7 +96,7 @@ module ModelContextProtocol
99
96
  end
100
97
 
101
98
  def flush_queued_messages
102
- return unless @transport && @enabled
99
+ return unless @transport
103
100
  @queued_messages.each do |params|
104
101
  @transport.send_notification("notifications/message", params)
105
102
  end
@@ -1,90 +1,149 @@
1
- require_relative "mcp_logger"
2
-
3
1
  module ModelContextProtocol
2
+ # Base settings container for MCP servers with two concrete subclasses:
3
+ # - {StdioConfiguration} for standalone scripts using stdin/stdout
4
+ # - {StreamableHttpConfiguration} for Rack applications using streamable HTTP with Redis
5
+ #
6
+ # Server.rb factory methods (with_stdio_transport, with_streamable_http_transport)
7
+ # instantiate the appropriate subclass, yield it to a block for population, validate
8
+ # it, then pass it to Router.new. Router reads pagination settings via pagination_options
9
+ # and queries transport capabilities via supports_list_changed? and apply_environment_variables?.
10
+ #
11
+ # The base class provides shared attributes (name, version, registry, pagination) and
12
+ # validation logic, while subclasses override transport_type and validate_transport! to
13
+ # enforce transport-specific constraints.
4
14
  class Server::Configuration
5
- # Raised when configured with invalid name.
15
+ # Signals that the server's identifying name is missing or not a String.
16
+ # Raised by validate! when name is nil or non-String.
6
17
  class InvalidServerNameError < StandardError; end
7
18
 
8
- # Raised when configured with invalid version.
19
+ # Signals that the server's version string is missing or not a String.
20
+ # Raised by validate! when version is nil or non-String.
9
21
  class InvalidServerVersionError < StandardError; end
10
22
 
11
- # Raised when configured with invalid title.
23
+ # Signals that the optional UI title is non-nil but not a String.
24
+ # Raised by validate_title! when title is set to a non-String value.
12
25
  class InvalidServerTitleError < StandardError; end
13
26
 
14
- # Raised when configured with invalid instructions.
27
+ # Signals that the optional LLM instructions are non-nil but not a String.
28
+ # Raised by validate_instructions! when instructions is set to a non-String value.
15
29
  class InvalidServerInstructionsError < StandardError; end
16
30
 
17
- # Raised when configured with invalid registry.
31
+ # Signals that the registry is missing or not a Registry instance.
32
+ # Raised by validate! when registry is nil or not a ModelContextProtocol::Server::Registry.
18
33
  class InvalidRegistryError < StandardError; end
19
34
 
20
- # Raised when a required environment variable is not set
35
+ # Signals that a required environment variable is absent (stdio transport only).
36
+ # Raised by StdioConfiguration#validate_environment_variables! when a variable
37
+ # declared via require_environment_variable is not set.
21
38
  class MissingRequiredEnvironmentVariable < StandardError; end
22
39
 
23
- # Raised when transport configuration is invalid
40
+ # Signals transport-specific validation failure (Redis missing for HTTP, stdout conflict for stdio).
41
+ # Raised by subclass implementations of validate_transport! when prerequisites are unmet.
24
42
  class InvalidTransportError < StandardError; end
25
43
 
26
- # Raised when an invalid log level is provided
27
- class InvalidLogLevelError < StandardError; end
28
-
29
- # Raised when pagination configuration is invalid
44
+ # Signals that pagination settings are invalid (negative sizes, out-of-range defaults).
45
+ # Raised by validate_pagination! when default_page_size exceeds max_page_size or values are non-positive.
30
46
  class InvalidPaginationError < StandardError; end
31
47
 
32
- # Valid MCP log levels per the specification
33
- VALID_LOG_LEVELS = %w[debug info notice warning error critical alert emergency].freeze
34
-
35
- attr_accessor :name, :registry, :version, :transport, :pagination, :title, :instructions
36
- attr_reader :logger
48
+ # @!attribute [rw] name
49
+ # @return [String] the server's identifying name (sent in initialize response serverInfo.name)
50
+ attr_accessor :name
51
+
52
+ # @!attribute [rw] version
53
+ # @return [String] the server's version string (sent in initialize response serverInfo.version)
54
+ attr_accessor :version
55
+
56
+ # @!attribute [rw] pagination
57
+ # @return [Hash, false, nil] pagination settings or false to disable;
58
+ # Router calls pagination_options to extract default_page_size, max_page_size, and cursor_ttl
59
+ # when handling list requests (resources/list, prompts/list, tools/list).
60
+ # Accepts Hash with :enabled, :default_page_size, :max_page_size, :cursor_ttl keys, or false to disable.
61
+ attr_accessor :pagination
62
+
63
+ # @!attribute [rw] title
64
+ # @return [String, nil] optional human-readable display title for Claude Desktop UI
65
+ # (sent in initialize response serverInfo.title if present)
66
+ attr_accessor :title
67
+
68
+ # @!attribute [rw] instructions
69
+ # @return [String, nil] optional guidance for LLMs on how to use the server
70
+ # (sent in initialize response instructions field if present)
71
+ attr_accessor :instructions
72
+
73
+ # @!attribute [r] client_logger
74
+ # @return [ClientLogger] logger for sending notifications/message to MCP clients via JSON-RPC;
75
+ # Router passes this to prompts, resources, and tools so they can log to the client
76
+ attr_reader :client_logger
77
+
78
+ # Lazily-built Ruby Logger for server-side diagnostics (not sent to clients).
79
+ # Reads from GlobalConfig::ServerLogging on first access so that
80
+ # Server.configure_server_logging can be called before or after the factory method.
81
+ #
82
+ # @return [ServerLogger] writes to stderr by default, or configured destination via configure_server_logging
83
+ def server_logger
84
+ @server_logger ||= begin
85
+ params = if ModelContextProtocol::Server::GlobalConfig::ServerLogging.configured?
86
+ ModelContextProtocol::Server::GlobalConfig::ServerLogging.logger_params
87
+ else
88
+ {}
89
+ end
90
+ ModelContextProtocol::Server::ServerLogger.new(**params)
91
+ end
92
+ end
37
93
 
94
+ # Initialize shared attributes and loggers for any configuration subclass.
95
+ # ClientLogger queues messages until a transport connects; ServerLogger is built
96
+ # lazily on first access via #server_logger.
38
97
  def initialize
39
- @logging_enabled = true
40
- @default_log_level = "info"
41
- @logger = ModelContextProtocol::Server::MCPLogger.new(
98
+ @client_logger = ModelContextProtocol::Server::ClientLogger.new(
42
99
  logger_name: "server",
43
- level: @default_log_level,
44
- enabled: @logging_enabled
100
+ level: "info"
45
101
  )
46
102
  end
47
103
 
48
- def logging_enabled?
49
- @logging_enabled
50
- end
51
-
52
- def logging_enabled=(value)
53
- @logging_enabled = value
54
- @logger = ModelContextProtocol::Server::MCPLogger.new(
55
- logger_name: "server",
56
- level: @default_log_level,
57
- enabled: value
58
- )
104
+ # Create and store a Registry from a block defining prompts, resources, and tools.
105
+ # Router queries the resulting registry to handle resources/list, tools/call, etc.
106
+ #
107
+ # @yieldparam (see Registry.new)
108
+ # @return [ModelContextProtocol::Server::Registry] the created registry
109
+ # @example
110
+ # config.registry do
111
+ # tools { register MyTool }
112
+ # end
113
+ def registry(&block)
114
+ return @registry unless block
115
+
116
+ @registry = ModelContextProtocol::Server::Registry.new(&block)
59
117
  end
60
118
 
61
- def default_log_level=(level)
62
- unless VALID_LOG_LEVELS.include?(level.to_s)
63
- raise InvalidLogLevelError, "Invalid log level: #{level}. Valid levels are: #{VALID_LOG_LEVELS.join(", ")}"
64
- end
65
-
66
- @default_log_level = level.to_s
67
- @logger.set_mcp_level(@default_log_level)
68
- end
119
+ # Identify the transport layer for this configuration.
120
+ # Subclasses return :stdio or :streamable_http; Server.start uses this to
121
+ # instantiate the correct Transport class (StdioTransport or StreamableHttpTransport).
122
+ #
123
+ # @return [Symbol, nil] nil in the base class (never instantiated directly)
124
+ def transport_type = nil
69
125
 
70
- def transport_type
71
- case transport
72
- when Hash
73
- transport[:type] || transport["type"]
74
- when Symbol, String
75
- transport.to_sym
76
- end
77
- end
126
+ # Determine whether the transport supports notifications/resources/list_changed
127
+ # and notifications/tools/list_changed. Router queries this when building the
128
+ # initialize response capabilities hash (adding listChanged: true to prompts/resources/tools).
129
+ # Only HTTP transport returns true (stdio can't push unsolicited notifications).
130
+ #
131
+ # @return [Boolean] false in base class and StdioConfiguration, true in StreamableHttpConfiguration
132
+ def supports_list_changed? = false
78
133
 
79
- def transport_options
80
- case transport
81
- when Hash
82
- transport.except(:type, "type").transform_keys(&:to_sym)
83
- else
84
- {}
85
- end
86
- end
134
+ # Determine whether Router should modify ENV before executing handlers.
135
+ # StdioConfiguration returns true because stdin/stdout scripts run single-threaded
136
+ # and ENV mutation is safe. StreamableHttpConfiguration returns false because
137
+ # ENV is global and modifying it in a multi-threaded Rack server creates race conditions.
138
+ #
139
+ # @return [Boolean] false in base class, overridden by subclasses
140
+ def apply_environment_variables? = false
87
141
 
142
+ # Check whether pagination is active for list responses (resources/list, prompts/list, tools/list).
143
+ # Router calls this before extracting pagination params; if false, it returns unpaginated results.
144
+ # Enabled by default (nil or true), or when pagination Hash has enabled != false.
145
+ #
146
+ # @return [Boolean] true unless pagination is explicitly set to false
88
147
  def pagination_enabled?
89
148
  return true if pagination.nil?
90
149
 
@@ -98,6 +157,12 @@ module ModelContextProtocol
98
157
  end
99
158
  end
100
159
 
160
+ # Extract normalized pagination settings for Router to pass to Pagination.extract_pagination_params.
161
+ # Router uses default_page_size and max_page_size to validate cursor and page size params from the client,
162
+ # and cursor_ttl to configure how long the Pagination module stores cursor state in memory.
163
+ #
164
+ # @return [Hash] pagination parameters with keys :enabled, :default_page_size, :max_page_size, :cursor_ttl;
165
+ # defaults are 100, 1000, and 3600 (1 hour) respectively
101
166
  def pagination_options
102
167
  case pagination
103
168
  when Hash
@@ -119,94 +184,106 @@ module ModelContextProtocol
119
184
  end
120
185
  end
121
186
 
187
+ # Verify all required attributes and transport-specific constraints.
188
+ # Called by Server.build_server (the factory method's internal logic) after yielding
189
+ # the configuration block but before constructing the Router. Ensures the configuration
190
+ # is complete and internally consistent.
191
+ #
192
+ # @raise [InvalidServerNameError] if name is nil or non-String
193
+ # @raise [InvalidRegistryError] if registry is nil or not a Registry instance
194
+ # @raise [InvalidServerVersionError] if version is nil or non-String
195
+ # @raise [InvalidTransportError] if transport prerequisites fail (subclass-specific)
196
+ # @raise [InvalidPaginationError] if page sizes are negative or out of range
197
+ # @raise [InvalidServerTitleError] if title is non-nil but not a String
198
+ # @raise [InvalidServerInstructionsError] if instructions is non-nil but not a String
199
+ # @return [void]
122
200
  def validate!
123
201
  raise InvalidServerNameError unless valid_name?
124
202
  raise InvalidRegistryError unless valid_registry?
125
203
  raise InvalidServerVersionError unless valid_version?
204
+
126
205
  validate_transport!
127
206
  validate_pagination!
128
207
  validate_title!
129
208
  validate_instructions!
130
-
131
- validate_environment_variables!
132
209
  end
133
210
 
134
- def environment_variables
135
- @environment_variables ||= {}
136
- end
137
-
138
- def environment_variable(key)
139
- environment_variables[key.to_s.upcase] || ENV[key.to_s.upcase] || nil
140
- end
141
-
142
- def require_environment_variable(key)
143
- required_environment_variables << key.to_s.upcase
144
- end
145
-
146
- # Programatically set an environment variable - useful if an alternative
147
- # to environment variables is used for security purposes. Despite being
148
- # more like 'configuration variables', these are called environment variables
149
- # to align with the Model Context Protocol terminology.
150
- #
151
- # see: https://modelcontextprotocol.io/docs/tools/debugging#environment-variables
211
+ # Access server-wide key-value storage merged with per-request session_context by Router.
212
+ # Router.effective_context merges this with Thread.current[:mcp_context][:session_context],
213
+ # then passes the result to prompts, resources, and tools so they can access both
214
+ # server-level (shared across all requests) and session-level (specific to HTTP session) data.
152
215
  #
153
- # @param key [String] The key to set the environment variable for
154
- # @param value [String] The value to set the environment variable to
155
- def set_environment_variable(key, value)
156
- environment_variables[key.to_s.upcase] = value
157
- end
158
-
216
+ # @return [Hash] the lazily-initialized context hash (defaults to {})
159
217
  def context
160
218
  @context ||= {}
161
219
  end
162
220
 
221
+ # Replace the server-wide context with a new hash.
222
+ # Router reads this via effective_context, merging it with session-level data before
223
+ # passing to handler implementations.
224
+ #
225
+ # @param context_hash [Hash] the new context to store (defaults to {})
226
+ # @return [Hash] the stored context
163
227
  def context=(context_hash = {})
164
228
  @context = context_hash
165
229
  end
166
230
 
167
231
  private
168
232
 
169
- def required_environment_variables
170
- @required_environment_variables ||= []
233
+ # Template method for subclass-specific transport validation.
234
+ # StdioConfiguration checks that required environment variables are set and that
235
+ # server_logger isn't writing to stdout (which would corrupt the stdio protocol).
236
+ # StreamableHttpConfiguration validates that redis_url is a valid Redis URL.
237
+ #
238
+ # @raise [InvalidTransportError] when transport prerequisites are unmet
239
+ # @raise [MissingRequiredEnvironmentVariable] when stdio transport requires an unset variable
240
+ # @return [void]
241
+ def validate_transport!
242
+ # Template method — subclasses override to add transport-specific validations
171
243
  end
172
244
 
173
- def validate_environment_variables!
174
- required_environment_variables.each do |key|
175
- raise MissingRequiredEnvironmentVariable, "#{key} is not set" unless environment_variable(key)
176
- end
245
+ # Template method for subclass-specific transport setup (side effects).
246
+ # Called by Server.build_server after validate! passes. Separated from validate_transport!
247
+ # because validation should be pure (no side effects), while setup performs actions like
248
+ # creating connection pools.
249
+ # StreamableHttpConfiguration overrides this to configure the Redis connection pool.
250
+ #
251
+ # @return [void]
252
+ def setup_transport!
253
+ # Template method — subclasses override to perform transport-specific setup
177
254
  end
178
255
 
256
+ # Check that name attribute is a non-nil String.
257
+ # Called by validate! to ensure the initialize response can be constructed.
258
+ #
259
+ # @return [Boolean] true if name is a String
179
260
  def valid_name?
180
261
  name&.is_a?(String)
181
262
  end
182
263
 
264
+ # Check that registry attribute is a Registry instance.
265
+ # Called by validate! to ensure Router can query tools, prompts, and resources.
266
+ #
267
+ # @return [Boolean] true if registry is a ModelContextProtocol::Server::Registry
183
268
  def valid_registry?
184
269
  registry&.is_a?(ModelContextProtocol::Server::Registry)
185
270
  end
186
271
 
272
+ # Check that version attribute is a non-nil String.
273
+ # Called by validate! to ensure the initialize response can be constructed.
274
+ #
275
+ # @return [Boolean] true if version is a String
187
276
  def valid_version?
188
277
  version&.is_a?(String)
189
278
  end
190
279
 
191
- def validate_transport!
192
- case transport_type
193
- when :streamable_http
194
- validate_streamable_http_transport!
195
- when :stdio, nil
196
- # stdio transport has no required options
197
- else
198
- raise InvalidTransportError, "Unknown transport type: #{transport_type}" if transport_type
199
- end
200
- end
201
-
202
- def validate_streamable_http_transport!
203
- unless ModelContextProtocol::Server::RedisConfig.configured?
204
- raise InvalidTransportError,
205
- "streamable_http transport requires Redis configuration. " \
206
- "Call ModelContextProtocol::Server.configure_redis in an initializer."
207
- end
208
- end
209
-
280
+ # Verify pagination settings are internally consistent (page sizes positive, defaults in range).
281
+ # Called by validate! only when pagination_enabled? is true; skipped if pagination is false.
282
+ # Ensures Router won't pass invalid params to Pagination.extract_pagination_params.
283
+ #
284
+ # @raise [InvalidPaginationError] if max_page_size <= 0, default_page_size <= 0,
285
+ # default_page_size > max_page_size, or cursor_ttl < 0
286
+ # @return [void]
210
287
  def validate_pagination!
211
288
  return unless pagination_enabled?
212
289
 
@@ -225,6 +302,11 @@ module ModelContextProtocol
225
302
  end
226
303
  end
227
304
 
305
+ # Check that the optional title attribute is nil or a String.
306
+ # Called by validate! to ensure the initialize response serverInfo.title is valid.
307
+ #
308
+ # @raise [InvalidServerTitleError] if title is non-nil and not a String
309
+ # @return [void]
228
310
  def validate_title!
229
311
  return if title.nil?
230
312
  return if title.is_a?(String)
@@ -232,6 +314,11 @@ module ModelContextProtocol
232
314
  raise InvalidServerTitleError, "Server title must be a string"
233
315
  end
234
316
 
317
+ # Check that the optional instructions attribute is nil or a String.
318
+ # Called by validate! to ensure the initialize response instructions field is valid.
319
+ #
320
+ # @raise [InvalidServerInstructionsError] if instructions is non-nil and not a String
321
+ # @return [void]
235
322
  def validate_instructions!
236
323
  return if instructions.nil?
237
324
  return if instructions.is_a?(String)
@@ -78,7 +78,7 @@ module ModelContextProtocol
78
78
  size:,
79
79
  title:,
80
80
  uri:
81
- ].serialized
81
+ ]
82
82
  end
83
83
  end
84
84
  end