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
|
@@ -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
|
|
14
|
+
require "stringio"
|
|
14
15
|
|
|
15
16
|
require_relative "../lib/model_context_protocol"
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
37
|
-
@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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
"
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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("←
|
|
178
|
-
[500, {"Content-Type" => "application/json"}, ['{"error": "
|
|
191
|
+
@logger.error("← Invalid transport response")
|
|
192
|
+
[500, {"Content-Type" => "application/json"}, ['{"error": "Invalid transport response"}']]
|
|
179
193
|
end
|
|
180
|
-
|
|
181
|
-
@logger.error("
|
|
182
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
266
|
-
|
|
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
|
|
279
|
-
|
|
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
|
-
|
|
284
|
-
|
|
265
|
+
ModelContextProtocol::Server.shutdown
|
|
266
|
+
webrick_server.shutdown
|
|
285
267
|
end
|
|
286
268
|
end
|
|
287
269
|
|
|
288
|
-
|
|
270
|
+
webrick_server.start
|