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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -2
- data/README.md +174 -978
- data/lib/model_context_protocol/rspec/helpers.rb +54 -0
- data/lib/model_context_protocol/rspec/matchers/be_mcp_error_response.rb +123 -0
- data/lib/model_context_protocol/rspec/matchers/be_valid_mcp_class.rb +103 -0
- data/lib/model_context_protocol/rspec/matchers/be_valid_mcp_prompt_response.rb +126 -0
- data/lib/model_context_protocol/rspec/matchers/be_valid_mcp_resource_response.rb +121 -0
- data/lib/model_context_protocol/rspec/matchers/be_valid_mcp_tool_response.rb +135 -0
- data/lib/model_context_protocol/rspec/matchers/have_audio_content.rb +109 -0
- data/lib/model_context_protocol/rspec/matchers/have_embedded_resource_content.rb +150 -0
- data/lib/model_context_protocol/rspec/matchers/have_image_content.rb +109 -0
- data/lib/model_context_protocol/rspec/matchers/have_message_count.rb +87 -0
- data/lib/model_context_protocol/rspec/matchers/have_message_with_role.rb +152 -0
- data/lib/model_context_protocol/rspec/matchers/have_resource_annotations.rb +135 -0
- data/lib/model_context_protocol/rspec/matchers/have_resource_blob.rb +108 -0
- data/lib/model_context_protocol/rspec/matchers/have_resource_link_content.rb +138 -0
- data/lib/model_context_protocol/rspec/matchers/have_resource_mime_type.rb +103 -0
- data/lib/model_context_protocol/rspec/matchers/have_resource_text.rb +112 -0
- data/lib/model_context_protocol/rspec/matchers/have_structured_content.rb +88 -0
- data/lib/model_context_protocol/rspec/matchers/have_text_content.rb +113 -0
- data/lib/model_context_protocol/rspec/matchers.rb +31 -0
- data/lib/model_context_protocol/rspec.rb +23 -0
- data/lib/model_context_protocol/server/client_logger.rb +1 -1
- data/lib/model_context_protocol/server/configuration.rb +195 -91
- data/lib/model_context_protocol/server/content_helpers.rb +1 -1
- data/lib/model_context_protocol/server/prompt.rb +0 -14
- data/lib/model_context_protocol/server/redis_client_proxy.rb +2 -14
- data/lib/model_context_protocol/server/redis_config.rb +5 -7
- data/lib/model_context_protocol/server/redis_pool_manager.rb +10 -13
- data/lib/model_context_protocol/server/registry.rb +8 -0
- data/lib/model_context_protocol/server/router.rb +279 -4
- data/lib/model_context_protocol/server/server_logger.rb +5 -2
- data/lib/model_context_protocol/server/stdio_configuration.rb +114 -0
- data/lib/model_context_protocol/server/stdio_transport/request_store.rb +0 -41
- data/lib/model_context_protocol/server/streamable_http_configuration.rb +218 -0
- data/lib/model_context_protocol/server/streamable_http_transport/event_counter.rb +0 -13
- data/lib/model_context_protocol/server/streamable_http_transport/notification_queue.rb +0 -41
- data/lib/model_context_protocol/server/streamable_http_transport/request_store.rb +0 -103
- data/lib/model_context_protocol/server/streamable_http_transport/server_request_store.rb +0 -64
- data/lib/model_context_protocol/server/streamable_http_transport/session_message_queue.rb +0 -58
- data/lib/model_context_protocol/server/streamable_http_transport/session_store.rb +17 -31
- data/lib/model_context_protocol/server/streamable_http_transport/stream_registry.rb +0 -34
- data/lib/model_context_protocol/server/streamable_http_transport.rb +192 -56
- data/lib/model_context_protocol/server/tool.rb +67 -1
- data/lib/model_context_protocol/server.rb +203 -262
- data/lib/model_context_protocol/version.rb +1 -1
- data/lib/model_context_protocol.rb +4 -1
- data/lib/puma/plugin/mcp.rb +39 -0
- data/tasks/mcp.rake +26 -0
- data/tasks/templates/dev-http-puma.erb +251 -0
- data/tasks/templates/dev-http.erb +166 -184
- data/tasks/templates/dev.erb +29 -7
- 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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
25
|
+
raise "Server already running. Call shutdown first." if @transport
|
|
20
26
|
|
|
21
|
-
|
|
22
|
-
when :stdio
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
284
|
-
def configure_redis(&block)
|
|
285
|
-
ModelContextProtocol::Server::RedisConfig.configure(&block)
|
|
286
|
-
end
|
|
221
|
+
private
|
|
287
222
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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,6 +1,9 @@
|
|
|
1
1
|
require "addressable/template"
|
|
2
2
|
|
|
3
|
-
Dir[File.join(__dir__, "model_context_protocol/", "**", "*.rb")]
|
|
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
|