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
@@ -0,0 +1,251 @@
1
+ #!/usr/bin/env <%= @ruby_path %>
2
+
3
+ require "bundler/setup"
4
+ require "fileutils"
5
+ require "rack"
6
+ require "puma"
7
+ require "puma/configuration"
8
+ require "puma/launcher"
9
+ require "securerandom"
10
+ require "redis"
11
+ require "logger"
12
+ require "json"
13
+ require "stringio"
14
+
15
+ require_relative "../lib/model_context_protocol"
16
+ require_relative "../lib/puma/plugin/mcp"
17
+
18
+ # Only require test handler files (prompts, resources, tools, etc.), not RSpec helpers
19
+ %w[prompts resources resource_templates tools completions].each do |subdir|
20
+ Dir[File.join(__dir__, "../spec/support/#{subdir}/**/*.rb")].each { |file| require file }
21
+ end
22
+
23
+ # Flag files for dynamic handler registration (checked at startup)
24
+ FLAGS_DIR = File.join(__dir__, '..', 'tmp', 'flags')
25
+ FileUtils.mkdir_p(FLAGS_DIR) unless Dir.exist?(FLAGS_DIR)
26
+
27
+ def flag_enabled?(flag_name)
28
+ File.exist?(File.join(FLAGS_DIR, flag_name))
29
+ end
30
+
31
+ # Configure server logging
32
+ ModelContextProtocol::Server.configure_server_logging do |config|
33
+ config.logdev = $stdout
34
+ config.level = Logger::DEBUG
35
+ config.progname = "MCP-Dev-Server"
36
+ end
37
+
38
+ # Configure MCP server (safe before fork - the Puma plugin handles starting after fork)
39
+ ModelContextProtocol::Server.with_streamable_http_transport do |config|
40
+ config.name = "MCP Development Server (Puma)"
41
+ config.version = "1.0.0"
42
+
43
+ config.redis_url = "redis://localhost:6379/0"
44
+ config.redis_pool_size = 10
45
+ config.redis_enable_reaper = true
46
+ config.redis_reaper_interval = 10
47
+ config.redis_idle_timeout = 15
48
+
49
+ config.pagination = {
50
+ default_page_size: 2,
51
+ max_page_size: 3,
52
+ cursor_ttl: 1800
53
+ }
54
+
55
+ config.require_sessions = true
56
+ config.session_ttl = 3600
57
+ config.allowed_origins = ["*"]
58
+
59
+ config.registry do
60
+ prompts do
61
+ register TestPrompt
62
+ register TestPromptWithCompletionClass if flag_enabled?('extra_prompts')
63
+ end
64
+
65
+ resources subscribe: true do
66
+ register TestResource
67
+ register TestAnnotatedResource
68
+ register TestProgressiveResource
69
+ register TestBinaryResource if flag_enabled?('extra_resources')
70
+ end
71
+
72
+ resource_templates do
73
+ register TestResourceTemplate
74
+ end
75
+
76
+ tools do
77
+ register TestToolWithStructuredContentResponse
78
+ register TestToolWithTextResponse
79
+ register TestToolWithImageResponse
80
+ register TestToolWithMixedContentResponse
81
+ register TestToolWithResourceResponse
82
+ register TestToolWithToolErrorResponse
83
+ register TestToolWithCancellableSleep
84
+ register TestToolWithProgressableAndCancellable
85
+ register TestToolWithAnnotations
86
+ register TestToolWithSecuritySchemes
87
+ register TestToolWithResourceLinkResponse
88
+ register TestToolWithAudioResponse if flag_enabled?('extra_tools')
89
+ end
90
+ end
91
+ end
92
+
93
+ # Rack application
94
+ class MCPHttpApp
95
+ def initialize
96
+ @logger = Logger.new(STDOUT)
97
+ @logger.level = Logger::INFO
98
+ @logger.formatter = proc do |severity, datetime, progname, msg|
99
+ request_id = Thread.current[:request_id] || "----"
100
+ "[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} [#{request_id}]: #{msg}\n"
101
+ end
102
+ end
103
+
104
+ def call(env)
105
+ request_id = SecureRandom.hex(4)
106
+ Thread.current[:request_id] = request_id
107
+
108
+ request = Rack::Request.new(env)
109
+ body_content = request.body.read
110
+
111
+ log_request(env, body_content)
112
+
113
+ env['rack.input'] = StringIO.new(body_content)
114
+
115
+ unless request.path == "/mcp"
116
+ return [404, {"Content-Type" => "application/json"}, ['{"error": "Not found"}']]
117
+ end
118
+
119
+ if request.request_method == "OPTIONS"
120
+ return [200, cors_headers, [""]]
121
+ end
122
+
123
+ begin
124
+ result = ModelContextProtocol::Server.serve(
125
+ env: env,
126
+ session_context: {
127
+ user_id: "dev-user-123",
128
+ request_id: request_id
129
+ }
130
+ )
131
+
132
+ handle_result(result, body_content)
133
+ rescue => e
134
+ @logger.error("Error handling request: #{e.message}")
135
+ @logger.debug("Full backtrace:\n#{e.backtrace.join("\n")}")
136
+ [500, {"Content-Type" => "application/json"}, [%Q({"error": "Internal server error: #{e.message}"})]]
137
+ ensure
138
+ Thread.current[:request_id] = nil
139
+ end
140
+ end
141
+
142
+ private
143
+
144
+ def log_request(env, body_content)
145
+ case env['REQUEST_METHOD']
146
+ when 'POST'
147
+ begin
148
+ request_json = JSON.parse(body_content)
149
+ method = request_json['method']
150
+ id = request_json['id']
151
+
152
+ if method&.start_with?('notifications/') || id.nil?
153
+ @logger.info("→ #{method} [NOTIFICATION]")
154
+ else
155
+ @logger.info("→ #{method} (id: #{id}) [REQUEST]")
156
+ end
157
+ @logger.info(" Request: #{body_content}")
158
+ rescue JSON::ParserError
159
+ @logger.info("→ POST #{env['PATH_INFO']} [INVALID JSON]")
160
+ end
161
+ when 'GET'
162
+ accept_header = env['HTTP_ACCEPT'] || ''
163
+ if accept_header.include?('text/event-stream')
164
+ @logger.info("→ GET #{env['PATH_INFO']} [SSE STREAM REQUEST]")
165
+ else
166
+ @logger.info("→ GET #{env['PATH_INFO']}")
167
+ end
168
+ when 'DELETE'
169
+ session_id = env['HTTP_MCP_SESSION_ID']
170
+ @logger.info("→ DELETE #{env['PATH_INFO']} [SESSION CLEANUP#{session_id ? ": #{session_id}" : ""}]")
171
+ else
172
+ @logger.info("→ #{env['REQUEST_METHOD']} #{env['PATH_INFO']}")
173
+ end
174
+ end
175
+
176
+ def handle_result(result, body_content)
177
+ case result
178
+ when Hash
179
+ if result[:stream]
180
+ @logger.info("← SSE STREAM OPENED [PERSISTENT CONNECTION]")
181
+ headers = (result[:headers] || {}).merge(cors_headers)
182
+ [200, headers, result[:stream_proc]]
183
+ elsif result[:json]
184
+ response_body = result[:json].to_json
185
+ status = result[:status] || 200
186
+ @logger.info("← RESPONSE (status: #{status})")
187
+ @logger.info(" Response: #{response_body}") unless status == 202
188
+ headers = (result[:headers] || {}).merge(cors_headers).merge("Content-Type" => "application/json")
189
+ [status, headers, [response_body]]
190
+ else
191
+ @logger.error("← Invalid transport response")
192
+ [500, {"Content-Type" => "application/json"}, ['{"error": "Invalid transport response"}']]
193
+ end
194
+ else
195
+ @logger.error("← Unexpected response format")
196
+ [500, {"Content-Type" => "application/json"}, ['{"error": "Unexpected response format"}']]
197
+ end
198
+ end
199
+
200
+ def cors_headers
201
+ {
202
+ "Access-Control-Allow-Origin" => "*",
203
+ "Access-Control-Allow-Methods" => "GET, POST, DELETE, OPTIONS",
204
+ "Access-Control-Allow-Headers" => "Content-Type, Accept, Mcp-Session-Id, MCP-Protocol-Version, Origin",
205
+ "Access-Control-Max-Age" => "86400"
206
+ }
207
+ end
208
+ end
209
+
210
+ # Parse command line options
211
+ workers = ENV.fetch("WEB_CONCURRENCY", "0").to_i
212
+ threads = ENV.fetch("PUMA_THREADS", "5").to_i
213
+ port = ENV.fetch("PORT", "9292").to_i
214
+
215
+ puts "=" * 60
216
+ puts "MCP Development Server (Puma)"
217
+ puts "=" * 60
218
+ puts "Port: #{port}"
219
+ puts "Workers: #{workers} (set WEB_CONCURRENCY to change)"
220
+ puts "Threads: #{threads} (set PUMA_THREADS to change)"
221
+ puts "Mode: #{workers > 0 ? "Clustered (forking)" : "Single (no forking)"}"
222
+ puts "=" * 60
223
+ puts ""
224
+ puts "Flag files (checked at startup only):"
225
+ puts " touch tmp/flags/extra_tools # Add TestToolWithAudioResponse"
226
+ puts " touch tmp/flags/extra_resources # Add TestBinaryResource"
227
+ puts " touch tmp/flags/extra_prompts # Add TestPromptWithCompletionClass"
228
+ puts ""
229
+
230
+ # Build the Rack app
231
+ app = Rack::Builder.new do
232
+ map '/mcp' do
233
+ run MCPHttpApp.new
234
+ end
235
+ end
236
+
237
+ # Configure Puma
238
+ puma_config = Puma::Configuration.new do |config|
239
+ config.bind "tcp://0.0.0.0:#{port}"
240
+ config.workers workers
241
+ config.threads 1, threads
242
+
243
+ # Use the MCP plugin to handle server lifecycle (start after fork, shutdown on exit)
244
+ config.plugin :mcp
245
+
246
+ config.app app
247
+ end
248
+
249
+ # Launch Puma
250
+ launcher = Puma::Launcher.new(puma_config)
251
+ launcher.run
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env <%= @ruby_path %>
2
2
 
3
3
  require "bundler/setup"
4
+ require "fileutils"
4
5
  require "rack"
5
6
  require 'rackup/handler/webrick'
6
7
  require "webrick"
@@ -10,31 +11,94 @@ require "securerandom"
10
11
  require "redis"
11
12
  require "logger"
12
13
  require "json"
13
- require 'stringio'
14
+ require "stringio"
14
15
 
15
16
  require_relative "../lib/model_context_protocol"
16
17
 
17
- ModelContextProtocol::Server.configure_redis do |config|
18
- config.redis_url = "redis://localhost:6379/0"
19
- config.pool_size = 10
20
- config.enable_reaper = true
21
- config.reaper_interval = 10
22
- config.idle_timeout = 15
18
+ # Only require test handler files (prompts, resources, tools, etc.), not RSpec helpers
19
+ %w[prompts resources resource_templates tools completions].each do |subdir|
20
+ Dir[File.join(__dir__, "../spec/support/#{subdir}/**/*.rb")].each { |file| require file }
23
21
  end
24
22
 
25
- Dir[File.join(__dir__, "../spec/support/**/*.rb")].each { |file| require file }
23
+ # Flag files for dynamic handler registration (checked at startup)
24
+ FLAGS_DIR = File.join(__dir__, '..', 'tmp', 'flags')
25
+ FileUtils.mkdir_p(FLAGS_DIR) unless Dir.exist?(FLAGS_DIR)
26
+
27
+ def flag_enabled?(flag_name)
28
+ File.exist?(File.join(FLAGS_DIR, flag_name))
29
+ end
26
30
 
27
- logger = Logger.new(STDOUT)
28
- logger.level = Logger::INFO
29
- logger.formatter = proc do |severity, datetime, progname, msg|
30
- request_id = Thread.current[:request_id] || "----"
31
- "[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} [#{request_id}]: #{msg}\n"
31
+ # Configure server logging
32
+ ModelContextProtocol::Server.configure_server_logging do |config|
33
+ config.logdev = $stdout
34
+ config.level = Logger::DEBUG
35
+ config.progname = "MCP-Dev-Server"
32
36
  end
33
37
 
38
+ # Configure MCP server
39
+ ModelContextProtocol::Server.with_streamable_http_transport do |config|
40
+ config.name = "MCP Development Server"
41
+ config.version = "1.0.0"
42
+
43
+ config.redis_url = "redis://localhost:6379/0"
44
+ config.redis_pool_size = 10
45
+ config.redis_enable_reaper = true
46
+ config.redis_reaper_interval = 10
47
+ config.redis_idle_timeout = 15
48
+
49
+ config.pagination = {
50
+ default_page_size: 2,
51
+ max_page_size: 3,
52
+ cursor_ttl: 1800
53
+ }
54
+
55
+ config.require_sessions = true
56
+ config.session_ttl = 3600
57
+ config.allowed_origins = ["*"]
58
+
59
+ config.registry do
60
+ prompts do
61
+ register TestPrompt
62
+ register TestPromptWithCompletionClass if flag_enabled?('extra_prompts')
63
+ end
64
+
65
+ resources subscribe: true do
66
+ register TestResource
67
+ register TestAnnotatedResource
68
+ register TestProgressiveResource
69
+ register TestBinaryResource if flag_enabled?('extra_resources')
70
+ end
71
+
72
+ resource_templates do
73
+ register TestResourceTemplate
74
+ end
75
+
76
+ tools do
77
+ register TestToolWithStructuredContentResponse
78
+ register TestToolWithTextResponse
79
+ register TestToolWithImageResponse
80
+ register TestToolWithMixedContentResponse
81
+ register TestToolWithResourceResponse
82
+ register TestToolWithToolErrorResponse
83
+ register TestToolWithCancellableSleep
84
+ register TestToolWithProgressableAndCancellable
85
+ register TestToolWithAnnotations
86
+ register TestToolWithSecuritySchemes
87
+ register TestToolWithResourceLinkResponse
88
+ register TestToolWithAudioResponse if flag_enabled?('extra_tools')
89
+ end
90
+ end
91
+ end
34
92
 
93
+ # Rack application
35
94
  class MCPHttpApp
36
- def initialize(logger)
37
- @logger = logger
95
+ def initialize
96
+ @logger = Logger.new(STDOUT)
97
+ @logger.level = Logger::INFO
98
+ @logger.formatter = proc do |severity, datetime, progname, msg|
99
+ request_id = Thread.current[:request_id] || "----"
100
+ "[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} [#{request_id}]: #{msg}\n"
101
+ end
38
102
  end
39
103
 
40
104
  def call(env)
@@ -44,6 +108,40 @@ class MCPHttpApp
44
108
  request = Rack::Request.new(env)
45
109
  body_content = request.body.read
46
110
 
111
+ log_request(env, body_content)
112
+
113
+ env['rack.input'] = StringIO.new(body_content)
114
+
115
+ unless request.path == "/mcp"
116
+ return [404, {"Content-Type" => "application/json"}, ['{"error": "Not found"}']]
117
+ end
118
+
119
+ if request.request_method == "OPTIONS"
120
+ return [200, cors_headers, [""]]
121
+ end
122
+
123
+ begin
124
+ result = ModelContextProtocol::Server.serve(
125
+ env: env,
126
+ session_context: {
127
+ user_id: "dev-user-123",
128
+ request_id: request_id
129
+ }
130
+ )
131
+
132
+ handle_result(result, body_content)
133
+ rescue => e
134
+ @logger.error("Error handling request: #{e.message}")
135
+ @logger.debug("Full backtrace:\n#{e.backtrace.join("\n")}")
136
+ [500, {"Content-Type" => "application/json"}, [%Q({"error": "Internal server error: #{e.message}"})]]
137
+ ensure
138
+ Thread.current[:request_id] = nil
139
+ end
140
+ end
141
+
142
+ private
143
+
144
+ def log_request(env, body_content)
47
145
  case env['REQUEST_METHOD']
48
146
  when 'POST'
49
147
  begin
@@ -51,9 +149,7 @@ class MCPHttpApp
51
149
  method = request_json['method']
52
150
  id = request_json['id']
53
151
 
54
- if method&.start_with?('notifications/')
55
- @logger.info("→ #{method} [NOTIFICATION]")
56
- elsif id.nil?
152
+ if method&.start_with?('notifications/') || id.nil?
57
153
  @logger.info("→ #{method} [NOTIFICATION]")
58
154
  else
59
155
  @logger.info("→ #{method} (id: #{id}) [REQUEST]")
@@ -61,7 +157,6 @@ class MCPHttpApp
61
157
  @logger.info(" Request: #{body_content}")
62
158
  rescue JSON::ParserError
63
159
  @logger.info("→ POST #{env['PATH_INFO']} [INVALID JSON]")
64
- @logger.info(" Request: #{body_content}")
65
160
  end
66
161
  when 'GET'
67
162
  accept_header = env['HTTP_ACCEPT'] || ''
@@ -70,173 +165,45 @@ class MCPHttpApp
70
165
  else
71
166
  @logger.info("→ GET #{env['PATH_INFO']}")
72
167
  end
73
- @logger.info(" Headers: Accept=#{accept_header}") unless accept_header.empty?
74
168
  when 'DELETE'
75
169
  session_id = env['HTTP_MCP_SESSION_ID']
76
- if session_id
77
- @logger.info("→ DELETE #{env['PATH_INFO']} [SESSION CLEANUP: #{session_id}]")
78
- else
79
- @logger.info("→ DELETE #{env['PATH_INFO']} [SESSION CLEANUP]")
80
- end
170
+ @logger.info("→ DELETE #{env['PATH_INFO']} [SESSION CLEANUP#{session_id ? ": #{session_id}" : ""}]")
81
171
  else
82
172
  @logger.info("→ #{env['REQUEST_METHOD']} #{env['PATH_INFO']}")
83
- @logger.info(" Request: #{body_content}") unless body_content.empty?
84
- end
85
-
86
- if ModelContextProtocol::Server::RedisConfig.configured?
87
- pool_stats = ModelContextProtocol::Server::RedisConfig.stats
88
- @logger.info(" Redis Pool: #{pool_stats}")
89
- end
90
-
91
- env['rack.input'] = StringIO.new(body_content)
92
- request = Rack::Request.new(env)
93
-
94
- unless request.path == "/mcp"
95
- return [404, {"Content-Type" => "application/json"}, ['{"error": "Not found"}']]
96
173
  end
174
+ end
97
175
 
98
- if request.request_method == "OPTIONS"
99
- return [200, {
100
- "Access-Control-Allow-Origin" => "*",
101
- "Access-Control-Allow-Methods" => "GET, POST, DELETE, OPTIONS",
102
- "Access-Control-Allow-Headers" => "Content-Type, Accept, Mcp-Session-Id, MCP-Protocol-Version, Origin",
103
- "Access-Control-Max-Age" => "86400"
104
- }, [""]]
105
- end
106
-
107
- transport_config = {
108
- type: :streamable_http,
109
- env:,
110
- require_sessions: false,
111
- session_ttl: 3600,
112
- allowed_origins: ["*"]
113
- }
114
-
115
- @logger.debug("Creating MCP server with transport config")
116
- server = create_mcp_server(transport_config)
117
- transport = nil
118
-
119
- begin
120
- @logger.debug("Starting MCP server")
121
- result = server.start
122
-
123
- if server.respond_to?(:transport)
124
- transport = server.transport
125
- end
126
-
127
- case result
128
- when Hash
129
- if result[:stream]
130
- @logger.info("← SSE STREAM OPENED [PERSISTENT CONNECTION]")
131
- @logger.info(" Connection will remain open for real-time notifications")
132
- headers = result[:headers] || {}
133
- headers["Access-Control-Allow-Origin"] = "*"
134
- headers["Access-Control-Allow-Methods"] = "GET, POST, DELETE, OPTIONS"
135
- headers["Access-Control-Allow-Headers"] = "Content-Type, Accept, Mcp-Session-Id, MCP-Protocol-Version, Origin"
136
-
137
- return [200, headers, result[:stream_proc]]
138
- elsif result[:json]
139
- response_body = result[:json].to_json
140
- status = result[:status] || 200
141
-
142
- begin
143
- response_json = result[:json]
144
- if response_json[:error]
145
- @logger.info("← ERROR RESPONSE (code: #{response_json[:error][:code]})")
146
- elsif status == 202
147
- @logger.info("← NOTIFICATION ACCEPTED [NO RESPONSE REQUIRED]")
148
- elsif response_json[:accepted] == true && status == 200
149
- method = request_json['method'] rescue 'unknown'
150
- id = request_json['id'] rescue 'unknown'
151
- @logger.info("← #{method} RESPONSE (id: #{id}) [DELIVERED VIA SSE STREAM]")
152
- elsif response_json[:result]
153
- method = request_json['method'] rescue 'unknown'
154
- @logger.info("← #{method} RESPONSE (id: #{response_json[:id]})")
155
- else
156
- @logger.info("← RESPONSE (status: #{status})")
157
- end
158
- @logger.info(" Response: #{response_body}") unless status == 202 && response_body == '{}'
159
- rescue
160
- @logger.info("← RESPONSE (status: #{status})")
161
- @logger.info(" Response: #{response_body}") unless response_body.empty?
162
- end
163
-
164
- headers = result[:headers] || {}
165
- headers["Content-Type"] = "application/json"
166
- headers["Access-Control-Allow-Origin"] = "*"
167
- headers["Access-Control-Allow-Methods"] = "GET, POST, DELETE, OPTIONS"
168
- headers["Access-Control-Allow-Headers"] = "Content-Type, Accept, Mcp-Session-Id, MCP-Protocol-Version, Origin"
169
-
170
- [result[:status] || 200, headers, [response_body]]
171
- else
172
- # Fallback
173
- @logger.error("← Invalid transport response")
174
- [500, {"Content-Type" => "application/json"}, ['{"error": "Invalid transport response"}']]
175
- end
176
+ def handle_result(result, body_content)
177
+ case result
178
+ when Hash
179
+ if result[:stream]
180
+ @logger.info(" SSE STREAM OPENED [PERSISTENT CONNECTION]")
181
+ headers = (result[:headers] || {}).merge(cors_headers)
182
+ [200, headers, result[:stream_proc]]
183
+ elsif result[:json]
184
+ response_body = result[:json].to_json
185
+ status = result[:status] || 200
186
+ @logger.info("← RESPONSE (status: #{status})")
187
+ @logger.info(" Response: #{response_body}") unless status == 202
188
+ headers = (result[:headers] || {}).merge(cors_headers).merge("Content-Type" => "application/json")
189
+ [status, headers, [response_body]]
176
190
  else
177
- @logger.error("← Unexpected response format")
178
- [500, {"Content-Type" => "application/json"}, ['{"error": "Unexpected response format"}']]
191
+ @logger.error("← Invalid transport response")
192
+ [500, {"Content-Type" => "application/json"}, ['{"error": "Invalid transport response"}']]
179
193
  end
180
- rescue => e
181
- @logger.error("Error handling request: #{e.message}")
182
- @logger.debug("Full backtrace:\n#{e.backtrace.join("\n")}")
183
- [500, {"Content-Type" => "application/json"}, [%Q({"error": "Internal server error: #{e.message}"})]]
184
- ensure
185
- transport&.cleanup if transport&.respond_to?(:cleanup)
186
- Thread.current[:request_id] = nil
194
+ else
195
+ @logger.error(" Unexpected response format")
196
+ [500, {"Content-Type" => "application/json"}, ['{"error": "Unexpected response format"}']]
187
197
  end
188
198
  end
189
199
 
190
- private
191
-
192
- def create_mcp_server(transport_config)
193
- ModelContextProtocol::Server.new do |config|
194
- config.name = "MCP Development Server"
195
- config.version = "1.0.0"
196
- config.logging_enabled = true
197
-
198
- config.pagination = {
199
- default_page_size: 2,
200
- max_page_size: 3,
201
- cursor_ttl: 1800
202
- }
203
-
204
- config.set_environment_variable("MCP_ENV", "development")
205
-
206
- config.context = {
207
- user_id: "123456",
208
- request_id: Thread.current[:request_id]
209
- }
210
-
211
- config.transport = transport_config
212
-
213
- config.registry = ModelContextProtocol::Server::Registry.new do
214
- prompts list_changed: true do
215
- register TestPrompt
216
- register TestPromptWithCompletionClass
217
- end
218
-
219
- resources list_changed: true, subscribe: true do
220
- register TestResource
221
- register TestAnnotatedResource
222
- register TestBinaryResource
223
- end
224
-
225
- resource_templates do
226
- register TestResourceTemplate
227
- end
228
-
229
- tools list_changed: true do
230
- register TestToolWithStructuredContentResponse
231
- register TestToolWithTextResponse
232
- register TestToolWithImageResponse
233
- register TestToolWithMixedContentResponse
234
- register TestToolWithResourceResponse
235
- register TestToolWithToolErrorResponse
236
- register TestToolWithCancellableSleep
237
- end
238
- end
239
- end
200
+ def cors_headers
201
+ {
202
+ "Access-Control-Allow-Origin" => "*",
203
+ "Access-Control-Allow-Methods" => "GET, POST, DELETE, OPTIONS",
204
+ "Access-Control-Allow-Headers" => "Content-Type, Accept, Mcp-Session-Id, MCP-Protocol-Version, Origin",
205
+ "Access-Control-Max-Age" => "86400"
206
+ }
240
207
  end
241
208
  end
242
209
 
@@ -244,11 +211,23 @@ use_ssl = ENV['SSL'] == 'true'
244
211
  port = use_ssl ? 9293 : 9292
245
212
  protocol = use_ssl ? 'https' : 'http'
246
213
 
247
- logger.info("Starting MCP #{protocol.upcase} Development Server on #{protocol}://localhost:#{port}/mcp")
214
+ puts "=" * 60
215
+ puts "MCP Development Server (WEBrick)"
216
+ puts "=" * 60
217
+ puts "URL: #{protocol}://localhost:#{port}/mcp"
218
+ puts "Transport: Streamable HTTP"
219
+ puts "=" * 60
220
+ puts ""
221
+ puts "Flag files (checked at startup only - restart server to apply changes):"
222
+ puts " touch tmp/flags/extra_tools # Add TestToolWithAudioResponse"
223
+ puts " touch tmp/flags/extra_resources # Add TestBinaryResource"
224
+ puts " touch tmp/flags/extra_prompts # Add TestPromptWithCompletionClass"
225
+ puts " rm tmp/flags/<flag> # Remove the handler"
226
+ puts ""
248
227
 
249
228
  app = Rack::Builder.new do
250
229
  map '/mcp' do
251
- run MCPHttpApp.new(logger)
230
+ run MCPHttpApp.new
252
231
  end
253
232
  end
254
233
 
@@ -262,8 +241,8 @@ if use_ssl
262
241
  key_path = File.join(__dir__, '..', 'tmp', 'ssl', 'server.key')
263
242
 
264
243
  unless File.exist?(cert_path) && File.exist?(key_path)
265
- logger.error("SSL certificates not found at tmp/ssl/")
266
- logger.error("Generate them with: openssl req -x509 -newkey rsa:4096 -keyout tmp/ssl/server.key -out tmp/ssl/server.crt -days 365 -nodes -subj \"/C=US/ST=Dev/L=Dev/O=Dev/CN=localhost\"")
244
+ $stderr.puts "SSL certificates not found at tmp/ssl/"
245
+ $stderr.puts "Generate them with: openssl req -x509 -newkey rsa:4096 -keyout tmp/ssl/server.key -out tmp/ssl/server.crt -days 365 -nodes -subj \"/C=US/ST=Dev/L=Dev/O=Dev/CN=localhost\""
267
246
  exit(1)
268
247
  end
269
248
 
@@ -275,14 +254,17 @@ if use_ssl
275
254
  )
276
255
  end
277
256
 
278
- server = WEBrick::HTTPServer.new(server_options)
279
- server.mount '/', Rackup::Handler::WEBrick, app
257
+ # Start the MCP server (lifecycle hook)
258
+ ModelContextProtocol::Server.start
259
+
260
+ webrick_server = WEBrick::HTTPServer.new(server_options)
261
+ webrick_server.mount '/', Rackup::Handler::WEBrick, app
280
262
 
281
263
  ['INT', 'TERM'].each do |signal|
282
264
  Signal.trap(signal) do
283
- server.shutdown
284
- exit(0)
265
+ ModelContextProtocol::Server.shutdown
266
+ webrick_server.shutdown
285
267
  end
286
268
  end
287
269
 
288
- server.start
270
+ webrick_server.start