model-context-protocol-rb 0.6.0 → 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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -2
  3. data/README.md +174 -978
  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/client_logger.rb +1 -1
  25. data/lib/model_context_protocol/server/configuration.rb +195 -91
  26. data/lib/model_context_protocol/server/content_helpers.rb +1 -1
  27. data/lib/model_context_protocol/server/prompt.rb +0 -14
  28. data/lib/model_context_protocol/server/redis_client_proxy.rb +2 -14
  29. data/lib/model_context_protocol/server/redis_config.rb +5 -7
  30. data/lib/model_context_protocol/server/redis_pool_manager.rb +10 -13
  31. data/lib/model_context_protocol/server/registry.rb +8 -0
  32. data/lib/model_context_protocol/server/router.rb +279 -4
  33. data/lib/model_context_protocol/server/server_logger.rb +5 -2
  34. data/lib/model_context_protocol/server/stdio_configuration.rb +114 -0
  35. data/lib/model_context_protocol/server/stdio_transport/request_store.rb +0 -41
  36. data/lib/model_context_protocol/server/streamable_http_configuration.rb +218 -0
  37. data/lib/model_context_protocol/server/streamable_http_transport/event_counter.rb +0 -13
  38. data/lib/model_context_protocol/server/streamable_http_transport/notification_queue.rb +0 -41
  39. data/lib/model_context_protocol/server/streamable_http_transport/request_store.rb +0 -103
  40. data/lib/model_context_protocol/server/streamable_http_transport/server_request_store.rb +0 -64
  41. data/lib/model_context_protocol/server/streamable_http_transport/session_message_queue.rb +0 -58
  42. data/lib/model_context_protocol/server/streamable_http_transport/session_store.rb +17 -31
  43. data/lib/model_context_protocol/server/streamable_http_transport/stream_registry.rb +0 -34
  44. data/lib/model_context_protocol/server/streamable_http_transport.rb +192 -56
  45. data/lib/model_context_protocol/server/tool.rb +67 -1
  46. data/lib/model_context_protocol/server.rb +203 -262
  47. data/lib/model_context_protocol/version.rb +1 -1
  48. data/lib/model_context_protocol.rb +4 -1
  49. data/lib/puma/plugin/mcp.rb +39 -0
  50. data/tasks/mcp.rake +26 -0
  51. data/tasks/templates/dev-http-puma.erb +251 -0
  52. data/tasks/templates/dev-http.erb +166 -184
  53. data/tasks/templates/dev.erb +29 -7
  54. metadata +26 -2
@@ -6,288 +6,229 @@ module ModelContextProtocol
6
6
  # Raised when invalid parameters are provided.
7
7
  class ParameterValidationError < StandardError; end
8
8
 
9
- 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
10
12
 
11
- def initialize
12
- @configuration = Configuration.new
13
- yield(@configuration) if block_given?
14
- @router = Router.new(configuration:)
15
- map_handlers
16
- 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
17
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]
18
24
  def start
19
- configuration.validate!
25
+ raise "Server already running. Call shutdown first." if @transport
20
26
 
21
- @transport = case configuration.transport_type
22
- when :stdio, nil
23
- 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
24
31
  when :streamable_http
25
- StreamableHttpTransport.new(
26
- router: @router,
27
- configuration: @configuration
28
- )
29
- else
30
- raise ArgumentError, "Unknown transport: #{configuration.transport_type}"
32
+ @transport = StreamableHttpTransport.new(router: @router, configuration: @configuration)
31
33
  end
32
-
33
- @transport.handle
34
34
  end
35
35
 
36
- private
37
-
38
- SUPPORTED_PROTOCOL_VERSIONS = ["2025-06-18"].freeze
39
- private_constant :SUPPORTED_PROTOCOL_VERSIONS
40
-
41
- LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS.first
42
- private_constant :LATEST_PROTOCOL_VERSION
43
-
44
- InitializeResponse = Data.define(:protocol_version, :capabilities, :server_info, :instructions) do
45
- def serialized
46
- response = {
47
- protocolVersion: protocol_version,
48
- capabilities: capabilities,
49
- serverInfo: server_info
50
- }
51
- response[:instructions] = instructions if instructions
52
- response
53
- 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)
54
49
  end
55
50
 
56
- PingResponse = Data.define do
57
- def serialized
58
- {}
59
- 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
60
59
  end
61
60
 
62
- LoggingSetLevelResponse = Data.define do
63
- def serialized
64
- {}
65
- 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?
66
67
  end
67
68
 
68
- def map_handlers
69
- router.map("initialize") do |message|
70
- client_protocol_version = message["params"]&.dig("protocolVersion")
71
-
72
- negotiated_version = if client_protocol_version && SUPPORTED_PROTOCOL_VERSIONS.include?(client_protocol_version)
73
- client_protocol_version
74
- else
75
- LATEST_PROTOCOL_VERSION
76
- end
77
-
78
- server_info = {
79
- name: configuration.name,
80
- version: configuration.version
81
- }
82
- server_info[:title] = configuration.title if configuration.title
83
-
84
- InitializeResponse[
85
- protocol_version: negotiated_version,
86
- capabilities: build_capabilities,
87
- server_info: server_info,
88
- instructions: configuration.instructions
89
- ]
90
- end
91
-
92
- router.map("ping") do
93
- PingResponse[]
94
- end
95
-
96
- router.map("logging/setLevel") do |message|
97
- level = message["params"]["level"]
98
-
99
- unless ClientLogger::VALID_LOG_LEVELS.include?(level)
100
- raise ParameterValidationError, "Invalid log level: #{level}. Valid levels are: #{ClientLogger::VALID_LOG_LEVELS.join(", ")}"
101
- end
102
-
103
- configuration.client_logger.set_mcp_level(level)
104
- LoggingSetLevelResponse[]
105
- end
106
-
107
- router.map("completion/complete") do |message|
108
- type = message["params"]["ref"]["type"]
109
-
110
- completion_source = case type
111
- when "ref/prompt"
112
- name = message["params"]["ref"]["name"]
113
- configuration.registry.find_prompt(name)
114
- when "ref/resource"
115
- uri = message["params"]["ref"]["uri"]
116
- configuration.registry.find_resource_template(uri)
117
- else
118
- raise ModelContextProtocol::Server::ParameterValidationError, "ref/type invalid"
119
- end
120
-
121
- arg_name, arg_value = message["params"]["argument"].values_at("name", "value")
122
-
123
- if completion_source
124
- completion_source.complete_for(arg_name, arg_value)
125
- else
126
- ModelContextProtocol::Server::NullCompletion.call(arg_name, arg_value)
127
- end
128
- end
129
-
130
- router.map("resources/list") do |message|
131
- params = message["params"] || {}
132
-
133
- if configuration.pagination_enabled? && Server::Pagination.pagination_requested?(params)
134
- opts = configuration.pagination_options
135
-
136
- pagination_params = Server::Pagination.extract_pagination_params(
137
- params,
138
- default_page_size: opts[:default_page_size],
139
- max_page_size: opts[:max_page_size]
140
- )
141
-
142
- configuration.registry.resources_data(
143
- cursor: pagination_params[:cursor],
144
- page_size: pagination_params[:page_size],
145
- cursor_ttl: opts[:cursor_ttl]
146
- )
147
- else
148
- configuration.registry.resources_data
149
- end
150
- rescue Server::Pagination::InvalidCursorError => e
151
- raise ParameterValidationError, e.message
152
- end
153
-
154
- router.map("resources/read") do |message|
155
- uri = message["params"]["uri"]
156
- resource = configuration.registry.find_resource(uri)
157
- unless resource
158
- raise ModelContextProtocol::Server::ParameterValidationError, "resource not found for #{uri}"
159
- end
160
-
161
- resource.call(configuration.client_logger, configuration.server_logger, configuration.context)
162
- end
163
-
164
- router.map("resources/templates/list") do |message|
165
- params = message["params"] || {}
166
-
167
- if configuration.pagination_enabled? && Server::Pagination.pagination_requested?(params)
168
- opts = configuration.pagination_options
169
-
170
- pagination_params = Server::Pagination.extract_pagination_params(
171
- params,
172
- default_page_size: opts[:default_page_size],
173
- max_page_size: opts[:max_page_size]
174
- )
175
-
176
- configuration.registry.resource_templates_data(
177
- cursor: pagination_params[:cursor],
178
- page_size: pagination_params[:page_size],
179
- cursor_ttl: opts[:cursor_ttl]
180
- )
181
- else
182
- configuration.registry.resource_templates_data
183
- end
184
- rescue Server::Pagination::InvalidCursorError => e
185
- raise ParameterValidationError, e.message
186
- end
187
-
188
- router.map("prompts/list") do |message|
189
- params = message["params"] || {}
190
-
191
- if configuration.pagination_enabled? && Server::Pagination.pagination_requested?(params)
192
- opts = configuration.pagination_options
193
-
194
- pagination_params = Server::Pagination.extract_pagination_params(
195
- params,
196
- default_page_size: opts[:default_page_size],
197
- max_page_size: opts[:max_page_size]
198
- )
199
-
200
- configuration.registry.prompts_data(
201
- cursor: pagination_params[:cursor],
202
- page_size: pagination_params[:page_size],
203
- cursor_ttl: opts[:cursor_ttl]
204
- )
205
- else
206
- configuration.registry.prompts_data
207
- end
208
- rescue Server::Pagination::InvalidCursorError => e
209
- raise ParameterValidationError, e.message
210
- end
211
-
212
- router.map("prompts/get") do |message|
213
- arguments = message["params"]["arguments"]
214
- symbolized_arguments = arguments.transform_keys(&:to_sym)
215
- configuration
216
- .registry
217
- .find_prompt(message["params"]["name"])
218
- .call(symbolized_arguments, configuration.client_logger, configuration.server_logger, configuration.context)
219
- end
220
-
221
- router.map("tools/list") do |message|
222
- params = message["params"] || {}
223
-
224
- if configuration.pagination_enabled? && Server::Pagination.pagination_requested?(params)
225
- opts = configuration.pagination_options
226
-
227
- pagination_params = Server::Pagination.extract_pagination_params(
228
- params,
229
- default_page_size: opts[:default_page_size],
230
- max_page_size: opts[:max_page_size]
231
- )
232
-
233
- configuration.registry.tools_data(
234
- cursor: pagination_params[:cursor],
235
- page_size: pagination_params[:page_size],
236
- cursor_ttl: opts[:cursor_ttl]
237
- )
238
- else
239
- configuration.registry.tools_data
240
- end
241
- rescue Server::Pagination::InvalidCursorError => e
242
- raise ParameterValidationError, e.message
243
- end
244
-
245
- router.map("tools/call") do |message|
246
- arguments = message["params"]["arguments"]
247
- symbolized_arguments = arguments.transform_keys(&:to_sym)
248
- configuration
249
- .registry
250
- .find_tool(message["params"]["name"])
251
- .call(symbolized_arguments, configuration.client_logger, configuration.server_logger, configuration.context)
252
- 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?
253
75
  end
254
76
 
255
- def build_capabilities
256
- {}.tap do |capabilities|
257
- capabilities[:completions] = {}
258
- capabilities[:logging] = {}
259
-
260
- registry = configuration.registry
261
-
262
- if !registry.instance_variable_get(:@prompts).empty?
263
- capabilities[:prompts] = {
264
- listChanged: registry.prompts_options[:list_changed]
265
- }.except(:completions).compact
266
- end
267
-
268
- if !registry.instance_variable_get(:@resources).empty?
269
- capabilities[:resources] = {
270
- subscribe: registry.resources_options[:subscribe],
271
- listChanged: registry.resources_options[:list_changed]
272
- }.compact
273
- end
274
-
275
- if !registry.instance_variable_get(:@tools).empty?
276
- capabilities[:tools] = {
277
- listChanged: registry.tools_options[:list_changed]
278
- }.compact
279
- 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
280
218
  end
281
219
  end
282
220
 
283
- class << self
284
- def configure_redis(&block)
285
- ModelContextProtocol::Server::RedisConfig.configure(&block)
286
- end
221
+ private
287
222
 
288
- def configure_server_logging(&block)
289
- ModelContextProtocol::Server::GlobalConfig::ServerLogging.configure(&block)
290
- end
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:)
291
232
  end
292
233
  end
293
234
  end
@@ -1,3 +1,3 @@
1
1
  module ModelContextProtocol
2
- VERSION = "0.6.0"
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
data/tasks/mcp.rake CHANGED
@@ -53,6 +53,32 @@ namespace :mcp do
53
53
  puts "Using Ruby path: #{ruby_path}"
54
54
  end
55
55
 
56
+ desc "Generate the Puma-based streamable HTTP development server executable with the correct Ruby path"
57
+ task :generate_puma_server do
58
+ destination_path = "bin/dev-http-puma"
59
+ template_path = File.expand_path("templates/dev-http-puma.erb", __dir__)
60
+
61
+ # Create directory if it doesn't exist
62
+ FileUtils.mkdir_p(File.dirname(destination_path))
63
+
64
+ # Get the Ruby path
65
+ ruby_path = detect_ruby_path
66
+
67
+ # Read and process the template
68
+ template = File.read(template_path)
69
+ content = template.gsub("<%= @ruby_path %>", ruby_path)
70
+
71
+ # Write the executable
72
+ File.write(destination_path, content)
73
+
74
+ # Set permissions
75
+ FileUtils.chmod(0o755, destination_path)
76
+
77
+ # Show success message
78
+ puts "\nCreated executable at: #{File.expand_path(destination_path)}"
79
+ puts "Using Ruby path: #{ruby_path}"
80
+ end
81
+
56
82
  def detect_ruby_path
57
83
  # Get Ruby version from project config
58
84
  ruby_version = get_project_ruby_version