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
@@ -9,13 +9,14 @@ module ModelContextProtocol
9
9
  include ModelContextProtocol::Server::ContentHelpers
10
10
  include ModelContextProtocol::Server::Progressable
11
11
 
12
- attr_reader :arguments, :context, :logger
12
+ attr_reader :arguments, :context, :client_logger, :server_logger
13
13
 
14
- def initialize(arguments, logger, context = {})
14
+ def initialize(arguments, client_logger, server_logger, context = {})
15
15
  validate!(arguments)
16
16
  @arguments = arguments
17
17
  @context = context
18
- @logger = logger
18
+ @client_logger = client_logger
19
+ @server_logger = server_logger
19
20
  end
20
21
 
21
22
  def call
@@ -82,7 +83,7 @@ module ModelContextProtocol
82
83
  end
83
84
 
84
85
  class << self
85
- attr_reader :name, :description, :title, :input_schema, :output_schema
86
+ attr_reader :name, :description, :title, :input_schema, :output_schema, :annotations, :security_schemes
86
87
 
87
88
  def define(&block)
88
89
  definition_dsl = DefinitionDSL.new
@@ -93,6 +94,8 @@ module ModelContextProtocol
93
94
  @title = definition_dsl.title
94
95
  @input_schema = definition_dsl.input_schema
95
96
  @output_schema = definition_dsl.output_schema
97
+ @annotations = definition_dsl.defined_annotations
98
+ @security_schemes = definition_dsl.security_schemes
96
99
  end
97
100
 
98
101
  def inherited(subclass)
@@ -101,10 +104,12 @@ module ModelContextProtocol
101
104
  subclass.instance_variable_set(:@title, @title)
102
105
  subclass.instance_variable_set(:@input_schema, @input_schema)
103
106
  subclass.instance_variable_set(:@output_schema, @output_schema)
107
+ subclass.instance_variable_set(:@annotations, @annotations&.dup)
108
+ subclass.instance_variable_set(:@security_schemes, @security_schemes)
104
109
  end
105
110
 
106
- def call(arguments, logger, context = {})
107
- new(arguments, logger, context).call
111
+ def call(arguments, client_logger, server_logger, context = {})
112
+ new(arguments, client_logger, server_logger, context).call
108
113
  rescue JSON::Schema::ValidationError => validation_error
109
114
  raise ModelContextProtocol::Server::ParameterValidationError, validation_error.message
110
115
  rescue OutputSchemaValidationError, ModelContextProtocol::Server::ResponseArgumentsError => tool_error
@@ -119,6 +124,9 @@ module ModelContextProtocol
119
124
  result = {name: @name, description: @description, inputSchema: @input_schema}
120
125
  result[:title] = @title if @title
121
126
  result[:outputSchema] = @output_schema if @output_schema
127
+ annotations_hash = @annotations&.serialized
128
+ result[:annotations] = annotations_hash if annotations_hash
129
+ result[:securitySchemes] = @security_schemes if @security_schemes
122
130
  result
123
131
  end
124
132
  end
@@ -148,6 +156,65 @@ module ModelContextProtocol
148
156
  @output_schema = instance_eval(&block) if block_given?
149
157
  @output_schema
150
158
  end
159
+
160
+ attr_reader :defined_annotations
161
+
162
+ def annotations(&block)
163
+ @defined_annotations = AnnotationsDSL.new
164
+ @defined_annotations.instance_eval(&block)
165
+ @defined_annotations
166
+ end
167
+
168
+ def security_schemes(&block)
169
+ @security_schemes = instance_eval(&block) if block_given?
170
+ @security_schemes
171
+ end
172
+ end
173
+
174
+ class AnnotationsDSL
175
+ def initialize
176
+ @read_only_hint = nil
177
+ @destructive_hint = nil
178
+ @idempotent_hint = nil
179
+ @open_world_hint = nil
180
+ end
181
+
182
+ def read_only_hint(value)
183
+ validate_boolean!(:read_only_hint, value)
184
+ @read_only_hint = value
185
+ end
186
+
187
+ def destructive_hint(value)
188
+ validate_boolean!(:destructive_hint, value)
189
+ @destructive_hint = value
190
+ end
191
+
192
+ def idempotent_hint(value)
193
+ validate_boolean!(:idempotent_hint, value)
194
+ @idempotent_hint = value
195
+ end
196
+
197
+ def open_world_hint(value)
198
+ validate_boolean!(:open_world_hint, value)
199
+ @open_world_hint = value
200
+ end
201
+
202
+ def serialized
203
+ result = {}
204
+ result[:readOnlyHint] = @read_only_hint unless @read_only_hint.nil?
205
+ result[:destructiveHint] = @destructive_hint unless @destructive_hint.nil?
206
+ result[:idempotentHint] = @idempotent_hint unless @idempotent_hint.nil?
207
+ result[:openWorldHint] = @open_world_hint unless @open_world_hint.nil?
208
+ result.empty? ? nil : result
209
+ end
210
+
211
+ private
212
+
213
+ def validate_boolean!(field, value)
214
+ unless value.is_a?(TrueClass) || value.is_a?(FalseClass)
215
+ raise ArgumentError, "#{field} must be a boolean, got: #{value.inspect}"
216
+ end
217
+ end
151
218
  end
152
219
  end
153
220
  end
@@ -1,5 +1,3 @@
1
- require "logger"
2
-
3
1
  module ModelContextProtocol
4
2
  class Server
5
3
  # Raised when invalid response arguments are provided.
@@ -8,284 +6,229 @@ module ModelContextProtocol
8
6
  # Raised when invalid parameters are provided.
9
7
  class ParameterValidationError < StandardError; end
10
8
 
11
- attr_reader :configuration, :router, :transport
9
+ # Raised by class-level start and serve when no server instance has been configured
10
+ # via with_stdio_transport or with_streamable_http_transport.
11
+ class NotConfiguredError < StandardError; end
12
12
 
13
- def initialize
14
- @configuration = Configuration.new
15
- yield(@configuration) if block_given?
16
- @router = Router.new(configuration:)
17
- map_handlers
18
- end
13
+ # @return [Configuration] the transport-specific configuration (StdioConfiguration or StreamableHttpConfiguration)
14
+ # @return [Router] the message router that dispatches JSON-RPC methods to handlers
15
+ # @return [Transport, nil] the active transport (StdioTransport or StreamableHttpTransport), or nil if not started
16
+ attr_reader :configuration, :router, :transport
19
17
 
18
+ # Activate the transport layer to begin processing MCP protocol messages.
19
+ # For stdio: blocks the calling thread while handling stdin/stdout communication.
20
+ # For HTTP: spawns background threads for Redis polling and stream monitoring, then returns immediately.
21
+ #
22
+ # @raise [RuntimeError] if transport is already running (prevents double-initialization)
23
+ # @return [void]
20
24
  def start
21
- configuration.validate!
25
+ raise "Server already running. Call shutdown first." if @transport
22
26
 
23
- @transport = case configuration.transport_type
24
- when :stdio, nil
25
- StdioTransport.new(router: @router, configuration: @configuration)
27
+ case configuration.transport_type
28
+ when :stdio
29
+ @transport = StdioTransport.new(router: @router, configuration: @configuration)
30
+ @transport.handle
26
31
  when :streamable_http
27
- StreamableHttpTransport.new(
28
- router: @router,
29
- configuration: @configuration
30
- )
31
- else
32
- raise ArgumentError, "Unknown transport: #{configuration.transport_type}"
32
+ @transport = StreamableHttpTransport.new(router: @router, configuration: @configuration)
33
33
  end
34
-
35
- @transport.handle
36
34
  end
37
35
 
38
- private
39
-
40
- SUPPORTED_PROTOCOL_VERSIONS = ["2025-06-18"].freeze
41
- private_constant :SUPPORTED_PROTOCOL_VERSIONS
42
-
43
- LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS.first
44
- private_constant :LATEST_PROTOCOL_VERSION
45
-
46
- InitializeResponse = Data.define(:protocol_version, :capabilities, :server_info, :instructions) do
47
- def serialized
48
- response = {
49
- protocolVersion: protocol_version,
50
- capabilities: capabilities,
51
- serverInfo: server_info
52
- }
53
- response[:instructions] = instructions if instructions
54
- response
55
- end
36
+ # Handle a single HTTP request through the streamable HTTP transport.
37
+ # Rack applications delegate each incoming request to this method.
38
+ #
39
+ # @param env [Hash] the Rack environment hash containing request details
40
+ # @param session_context [Hash] per-session data (e.g., user_id) stored during initialization
41
+ # @raise [RuntimeError] if transport hasn't been started via start
42
+ # @raise [RuntimeError] if called on stdio transport (HTTP-only method)
43
+ # @return [Hash] Rack response with :status, :headers, and either :json or :stream/:stream_proc
44
+ def serve(env:, session_context: {})
45
+ raise "Server not running. Call start first." unless @transport
46
+ raise "serve is only available for streamable_http transport" unless configuration.transport_type == :streamable_http
47
+
48
+ @transport.handle(env: env, session_context: session_context)
56
49
  end
57
50
 
58
- PingResponse = Data.define do
59
- def serialized
60
- {}
61
- end
51
+ # Tear down the transport and release resources.
52
+ # For stdio: no-ops (StdioTransport doesn't implement shutdown).
53
+ # For HTTP: stops background threads and closes active SSE streams.
54
+ #
55
+ # @return [void]
56
+ def shutdown
57
+ @transport.shutdown if @transport.respond_to?(:shutdown)
58
+ @transport = nil
62
59
  end
63
60
 
64
- LoggingSetLevelResponse = Data.define do
65
- def serialized
66
- {}
67
- end
61
+ # Query whether the server has been configured with a transport type.
62
+ # The Puma plugin checks this before attempting to start the server.
63
+ #
64
+ # @return [Boolean] true if with_stdio_transport or with_streamable_http_transport has been called
65
+ def configured?
66
+ !@configuration.nil?
68
67
  end
69
68
 
70
- def map_handlers
71
- router.map("initialize") do |message|
72
- client_protocol_version = message["params"]&.dig("protocolVersion")
73
-
74
- negotiated_version = if client_protocol_version && SUPPORTED_PROTOCOL_VERSIONS.include?(client_protocol_version)
75
- client_protocol_version
76
- else
77
- LATEST_PROTOCOL_VERSION
78
- end
79
-
80
- server_info = {
81
- name: configuration.name,
82
- version: configuration.version
83
- }
84
- server_info[:title] = configuration.title if configuration.title
85
-
86
- InitializeResponse[
87
- protocol_version: negotiated_version,
88
- capabilities: build_capabilities,
89
- server_info: server_info,
90
- instructions: configuration.instructions
91
- ]
92
- end
93
-
94
- router.map("ping") do
95
- PingResponse[]
96
- end
97
-
98
- router.map("logging/setLevel") do |message|
99
- level = message["params"]["level"]
100
-
101
- unless Configuration::VALID_LOG_LEVELS.include?(level)
102
- raise ParameterValidationError, "Invalid log level: #{level}. Valid levels are: #{Configuration::VALID_LOG_LEVELS.join(", ")}"
103
- end
104
-
105
- configuration.logger.set_mcp_level(level)
106
- LoggingSetLevelResponse[]
107
- end
108
-
109
- router.map("completion/complete") do |message|
110
- type = message["params"]["ref"]["type"]
111
-
112
- completion_source = case type
113
- when "ref/prompt"
114
- name = message["params"]["ref"]["name"]
115
- configuration.registry.find_prompt(name)
116
- when "ref/resource"
117
- uri = message["params"]["ref"]["uri"]
118
- configuration.registry.find_resource_template(uri)
119
- else
120
- raise ModelContextProtocol::Server::ParameterValidationError, "ref/type invalid"
121
- end
122
-
123
- arg_name, arg_value = message["params"]["argument"].values_at("name", "value")
124
-
125
- if completion_source
126
- completion_source.complete_for(arg_name, arg_value)
127
- else
128
- ModelContextProtocol::Server::NullCompletion.call(arg_name, arg_value)
129
- end
130
- end
131
-
132
- router.map("resources/list") do |message|
133
- params = message["params"] || {}
134
-
135
- if configuration.pagination_enabled? && Server::Pagination.pagination_requested?(params)
136
- opts = configuration.pagination_options
137
-
138
- pagination_params = Server::Pagination.extract_pagination_params(
139
- params,
140
- default_page_size: opts[:default_page_size],
141
- max_page_size: opts[:max_page_size]
142
- )
143
-
144
- configuration.registry.resources_data(
145
- cursor: pagination_params[:cursor],
146
- page_size: pagination_params[:page_size],
147
- cursor_ttl: opts[:cursor_ttl]
148
- )
149
- else
150
- configuration.registry.resources_data
151
- end
152
- rescue Server::Pagination::InvalidCursorError => e
153
- raise ParameterValidationError, e.message
154
- end
155
-
156
- router.map("resources/read") do |message|
157
- uri = message["params"]["uri"]
158
- resource = configuration.registry.find_resource(uri)
159
- unless resource
160
- raise ModelContextProtocol::Server::ParameterValidationError, "resource not found for #{uri}"
161
- end
162
-
163
- resource.call
164
- end
165
-
166
- router.map("resources/templates/list") do |message|
167
- params = message["params"] || {}
168
-
169
- if configuration.pagination_enabled? && Server::Pagination.pagination_requested?(params)
170
- opts = configuration.pagination_options
171
-
172
- pagination_params = Server::Pagination.extract_pagination_params(
173
- params,
174
- default_page_size: opts[:default_page_size],
175
- max_page_size: opts[:max_page_size]
176
- )
177
-
178
- configuration.registry.resource_templates_data(
179
- cursor: pagination_params[:cursor],
180
- page_size: pagination_params[:page_size],
181
- cursor_ttl: opts[:cursor_ttl]
182
- )
183
- else
184
- configuration.registry.resource_templates_data
185
- end
186
- rescue Server::Pagination::InvalidCursorError => e
187
- raise ParameterValidationError, e.message
188
- end
189
-
190
- router.map("prompts/list") do |message|
191
- params = message["params"] || {}
192
-
193
- if configuration.pagination_enabled? && Server::Pagination.pagination_requested?(params)
194
- opts = configuration.pagination_options
195
-
196
- pagination_params = Server::Pagination.extract_pagination_params(
197
- params,
198
- default_page_size: opts[:default_page_size],
199
- max_page_size: opts[:max_page_size]
200
- )
201
-
202
- configuration.registry.prompts_data(
203
- cursor: pagination_params[:cursor],
204
- page_size: pagination_params[:page_size],
205
- cursor_ttl: opts[:cursor_ttl]
206
- )
207
- else
208
- configuration.registry.prompts_data
209
- end
210
- rescue Server::Pagination::InvalidCursorError => e
211
- raise ParameterValidationError, e.message
212
- end
213
-
214
- router.map("prompts/get") do |message|
215
- arguments = message["params"]["arguments"]
216
- symbolized_arguments = arguments.transform_keys(&:to_sym)
217
- configuration
218
- .registry
219
- .find_prompt(message["params"]["name"])
220
- .call(symbolized_arguments, configuration.logger, configuration.context)
221
- end
222
-
223
- router.map("tools/list") do |message|
224
- params = message["params"] || {}
225
-
226
- if configuration.pagination_enabled? && Server::Pagination.pagination_requested?(params)
227
- opts = configuration.pagination_options
228
-
229
- pagination_params = Server::Pagination.extract_pagination_params(
230
- params,
231
- default_page_size: opts[:default_page_size],
232
- max_page_size: opts[:max_page_size]
233
- )
234
-
235
- configuration.registry.tools_data(
236
- cursor: pagination_params[:cursor],
237
- page_size: pagination_params[:page_size],
238
- cursor_ttl: opts[:cursor_ttl]
239
- )
240
- else
241
- configuration.registry.tools_data
242
- end
243
- rescue Server::Pagination::InvalidCursorError => e
244
- raise ParameterValidationError, e.message
245
- end
246
-
247
- router.map("tools/call") do |message|
248
- arguments = message["params"]["arguments"]
249
- symbolized_arguments = arguments.transform_keys(&:to_sym)
250
- configuration
251
- .registry
252
- .find_tool(message["params"]["name"])
253
- .call(symbolized_arguments, configuration.logger, configuration.context)
254
- end
69
+ # Query whether the server's transport layer is actively processing messages.
70
+ # The Puma plugin checks this to avoid redundant start calls and to guard shutdown.
71
+ #
72
+ # @return [Boolean] true if start has been called and transport is initialized
73
+ def running?
74
+ !@transport.nil?
255
75
  end
256
76
 
257
- def build_capabilities
258
- {}.tap do |capabilities|
259
- capabilities[:completions] = {}
260
- capabilities[:logging] = {} if configuration.logging_enabled?
261
-
262
- registry = configuration.registry
263
-
264
- if registry.prompts_options.any? && !registry.instance_variable_get(:@prompts).empty?
265
- capabilities[:prompts] = {
266
- listChanged: registry.prompts_options[:list_changed]
267
- }.except(:completions).compact
268
- end
269
-
270
- if registry.resources_options.any? && !registry.instance_variable_get(:@resources).empty?
271
- capabilities[:resources] = {
272
- subscribe: registry.resources_options[:subscribe],
273
- listChanged: registry.resources_options[:list_changed]
274
- }.compact
275
- end
276
-
277
- if registry.tools_options.any? && !registry.instance_variable_get(:@tools).empty?
278
- capabilities[:tools] = {
279
- listChanged: registry.tools_options[:list_changed]
280
- }.compact
281
- end
77
+ class << self
78
+ # @return [Server, nil] the singleton server instance created by with_stdio_transport or with_streamable_http_transport
79
+ attr_accessor :instance
80
+
81
+ # Factory method for creating a server with standard input/output transport.
82
+ # For standalone scripts that communicate over stdin/stdout (e.g., Claude Desktop integration).
83
+ # Yields a StdioConfiguration for setting name, version, registry, and environment variables.
84
+ #
85
+ # @yieldparam config [StdioConfiguration] the configuration to populate
86
+ # @return [Server] the configured server instance (also stored in Server.instance)
87
+ # @example
88
+ # server = ModelContextProtocol::Server.with_stdio_transport do |config|
89
+ # config.name = "My MCP Server"
90
+ # config.registry { tools { register MyTool } }
91
+ # end
92
+ # server.start # blocks while handling stdio
93
+ def with_stdio_transport(&block)
94
+ build_server(StdioConfiguration.new, &block)
95
+ end
96
+
97
+ # Factory method for creating a server with streamable HTTP transport.
98
+ # For Rack applications that serve multiple clients over HTTP with Redis-backed session coordination.
99
+ # Yields a StreamableHttpConfiguration for setting name, version, registry, session requirements, and CORS.
100
+ #
101
+ # @yieldparam config [StreamableHttpConfiguration] the configuration to populate
102
+ # @return [Server] the configured server instance (also stored in Server.instance)
103
+ # @raise [InvalidTransportError] if redis_url is not set or invalid
104
+ # @example
105
+ # server = ModelContextProtocol::Server.with_streamable_http_transport do |config|
106
+ # config.name = "My HTTP MCP Server"
107
+ # config.redis_url = ENV.fetch("REDIS_URL")
108
+ # config.require_sessions = true
109
+ # config.allowed_origins = ["*"]
110
+ # end
111
+ # server.start # spawns background threads, returns immediately
112
+ def with_streamable_http_transport(&block)
113
+ build_server(StreamableHttpConfiguration.new, &block)
114
+ end
115
+
116
+ # Configure global server-side logging (distinct from client-facing logs sent via JSON-RPC).
117
+ # Applies to all server instances; typically called once per application.
118
+ # For stdio transport, logdev must not be $stdout (would corrupt protocol messages).
119
+ #
120
+ # @yieldparam config [GlobalConfig::ServerLogging] the logging configuration
121
+ # @return [void]
122
+ # @example
123
+ # ModelContextProtocol::Server.configure_server_logging do |logger|
124
+ # logger.level = Logger::DEBUG
125
+ # logger.logdev = $stderr # or a file for stdio transport
126
+ # end
127
+ def configure_server_logging(&block)
128
+ Server::GlobalConfig::ServerLogging.configure(&block)
129
+ instance&.configuration&.instance_variable_set(:@server_logger, nil)
130
+ end
131
+
132
+ # Class-level delegations that forward to the singleton instance.
133
+ # Provided as a convenience for web server integrations (e.g., the Puma plugin in
134
+ # lib/puma/plugin/mcp.rb) that manage the server lifecycle through hooks rather
135
+ # than holding a direct reference to the server instance.
136
+
137
+ # Query whether any server instance has been configured.
138
+ # Returns false when no instance exists — the server genuinely isn't configured yet,
139
+ # so callers can use this as a guard before calling start.
140
+ #
141
+ # @return [Boolean] true if with_stdio_transport or with_streamable_http_transport has been called
142
+ def configured?
143
+ instance&.configured? || false
144
+ end
145
+
146
+ # Query whether any server instance is actively processing messages.
147
+ # Returns false when no instance exists, allowing callers to guard both
148
+ # start (to avoid redundant starts) and shutdown (to skip if not running).
149
+ #
150
+ # @return [Boolean] true if a server instance exists and its transport is initialized
151
+ def running?
152
+ instance&.running? || false
153
+ end
154
+
155
+ # Activate the transport layer to begin processing MCP protocol messages.
156
+ # Raises when no instance exists because a caller who forgot to invoke a factory method
157
+ # would otherwise get silent nil. Web server integrations like the Puma plugin guard
158
+ # with configured? first, but direct callers need the error.
159
+ #
160
+ # @raise [NotConfiguredError] if with_stdio_transport or with_streamable_http_transport hasn't been called
161
+ # @return [void]
162
+ def start
163
+ raise NotConfiguredError, "Server not configured. Call with_stdio_transport or with_streamable_http_transport first." unless instance
164
+ instance.start
165
+ end
166
+
167
+ # Handle a single HTTP request by forwarding to the instance's serve method.
168
+ # Used by Rails/Sinatra/Rack controllers as an alternative to calling Server.instance.serve directly.
169
+ # Raises when no instance exists for the same reason as start — a controller receiving
170
+ # requests without a configured server is always a misconfiguration.
171
+ #
172
+ # @param env [Hash] the Rack environment hash containing request details
173
+ # @param session_context [Hash] per-session data (e.g., user_id) stored during initialization
174
+ # @raise [NotConfiguredError] if with_streamable_http_transport hasn't been called
175
+ # @return [Hash] Rack response with :status, :headers, and either :json or :stream/:stream_proc
176
+ def serve(env:, session_context: {})
177
+ raise NotConfiguredError, "Server not configured. Call with_streamable_http_transport first." unless instance
178
+ instance.serve(env: env, session_context: session_context)
179
+ end
180
+
181
+ # Tear down the transport and release resources if a server is running.
182
+ # Safe-navigates when no instance exists because callers in cleanup paths
183
+ # (signal handlers, web server shutdown hooks, test teardown) need this to
184
+ # work unconditionally.
185
+ #
186
+ # @return [void]
187
+ def shutdown
188
+ instance&.shutdown
189
+ end
190
+
191
+ # Tear down the transport and clear the singleton instance to allow reconfiguration.
192
+ # Safe-navigates when no instance exists because test teardown (before/after hooks)
193
+ # must succeed even when a test fails before the server is initialized.
194
+ #
195
+ # @return [void]
196
+ def reset!
197
+ instance&.shutdown
198
+ self.instance = nil
199
+ end
200
+
201
+ private
202
+
203
+ # Internal factory logic shared by with_stdio_transport and with_streamable_http_transport.
204
+ # Validates configuration, creates a server instance without calling initialize (via allocate),
205
+ # initializes it with the configuration, and stores it in the singleton.
206
+ #
207
+ # @param config [Configuration] the transport-specific configuration subclass
208
+ # @yieldparam config [Configuration] if a block is given, yields for additional setup
209
+ # @return [Server] the configured and stored server instance
210
+ def build_server(config)
211
+ yield(config) if block_given?
212
+ config.validate!
213
+ config.send(:setup_transport!)
214
+ server = allocate
215
+ server.send(:initialize_from_configuration, config)
216
+ self.instance = server
217
+ server
282
218
  end
283
219
  end
284
220
 
285
- class << self
286
- def configure_redis(&block)
287
- RedisConfig.configure(&block)
288
- end
221
+ private
222
+
223
+ # Internal initializer called by build_server after allocate.
224
+ # Bypasses the standard initialize to allow configuration-driven construction.
225
+ # Creates the Router that maps JSON-RPC methods to handlers based on the registry.
226
+ #
227
+ # @param configuration [Configuration] the validated transport-specific configuration
228
+ # @return [void]
229
+ def initialize_from_configuration(configuration)
230
+ @configuration = configuration
231
+ @router = Router.new(configuration:)
289
232
  end
290
233
  end
291
234
  end
@@ -1,3 +1,3 @@
1
1
  module ModelContextProtocol
2
- VERSION = "0.5.1"
2
+ VERSION = "0.7.0"
3
3
  end
@@ -1,6 +1,9 @@
1
1
  require "addressable/template"
2
2
 
3
- Dir[File.join(__dir__, "model_context_protocol/", "**", "*.rb")].sort.each { |file| require_relative file }
3
+ Dir[File.join(__dir__, "model_context_protocol/", "**", "*.rb")]
4
+ .reject { |file| file.include?("/rspec") }
5
+ .sort
6
+ .each { |file| require_relative file }
4
7
 
5
8
  ##
6
9
  # Top-level namespace
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "puma/plugin"
4
+
5
+ Puma::Plugin.create do
6
+ # Capture the DSL reference for deferred hook registration.
7
+ def config(dsl)
8
+ @dsl = dsl
9
+ end
10
+
11
+ # Register lifecycle hooks after configuration is finalized.
12
+ # Using launcher.clustered? ensures reliable mode detection,
13
+ # avoiding issues where config-time checks may not reflect
14
+ # the final runtime worker count.
15
+ def start(launcher)
16
+ if (launcher.options[:workers] || 0) > 0
17
+ @dsl.before_worker_boot { start_mcp_server }
18
+ @dsl.before_worker_shutdown { shutdown_mcp_server }
19
+ else
20
+ launcher.events.after_booted { start_mcp_server }
21
+ launcher.events.after_stopped { shutdown_mcp_server }
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def start_mcp_server
28
+ return unless ModelContextProtocol::Server.configured?
29
+ return if ModelContextProtocol::Server.running?
30
+
31
+ ModelContextProtocol::Server.start
32
+ end
33
+
34
+ def shutdown_mcp_server
35
+ return unless ModelContextProtocol::Server.running?
36
+
37
+ ModelContextProtocol::Server.shutdown
38
+ end
39
+ end