mcp 0.1.0 → 0.3.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/.cursor/rules/release-changelogs.mdc +11 -24
- data/.github/workflows/release.yml +25 -0
- data/.rubocop.yml +5 -3
- data/CHANGELOG.md +57 -0
- data/Gemfile +16 -6
- data/README.md +439 -61
- data/examples/README.md +197 -0
- data/examples/http_client.rb +184 -0
- data/examples/http_server.rb +171 -0
- data/examples/stdio_server.rb +6 -6
- data/examples/streamable_http_client.rb +203 -0
- data/examples/streamable_http_server.rb +173 -0
- data/lib/mcp/client/http.rb +88 -0
- data/lib/mcp/client/tool.rb +16 -0
- data/lib/mcp/client.rb +88 -0
- data/lib/mcp/configuration.rb +22 -3
- data/lib/mcp/methods.rb +55 -33
- data/lib/mcp/prompt.rb +15 -4
- data/lib/mcp/resource.rb +8 -6
- data/lib/mcp/resource_template.rb +8 -6
- data/lib/mcp/server/capabilities.rb +96 -0
- data/lib/mcp/server/transports/stdio_transport.rb +57 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +301 -0
- data/lib/mcp/server.rb +116 -52
- data/lib/mcp/tool/annotations.rb +4 -4
- data/lib/mcp/tool/input_schema.rb +49 -1
- data/lib/mcp/tool/output_schema.rb +66 -0
- data/lib/mcp/tool/response.rb +15 -4
- data/lib/mcp/tool.rb +38 -7
- data/lib/mcp/transport.rb +16 -4
- data/lib/mcp/transports/stdio.rb +8 -28
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +20 -12
- data/mcp.gemspec +1 -2
- metadata +21 -24
data/lib/mcp/methods.rb
CHANGED
@@ -19,8 +19,20 @@ module MCP
|
|
19
19
|
TOOLS_CALL = "tools/call"
|
20
20
|
TOOLS_LIST = "tools/list"
|
21
21
|
|
22
|
+
ROOTS_LIST = "roots/list"
|
22
23
|
SAMPLING_CREATE_MESSAGE = "sampling/createMessage"
|
23
24
|
|
25
|
+
# Notification methods
|
26
|
+
NOTIFICATIONS_INITIALIZED = "notifications/initialized"
|
27
|
+
NOTIFICATIONS_TOOLS_LIST_CHANGED = "notifications/tools/list_changed"
|
28
|
+
NOTIFICATIONS_PROMPTS_LIST_CHANGED = "notifications/prompts/list_changed"
|
29
|
+
NOTIFICATIONS_RESOURCES_LIST_CHANGED = "notifications/resources/list_changed"
|
30
|
+
NOTIFICATIONS_RESOURCES_UPDATED = "notifications/resources/updated"
|
31
|
+
NOTIFICATIONS_ROOTS_LIST_CHANGED = "notifications/roots/list_changed"
|
32
|
+
NOTIFICATIONS_MESSAGE = "notifications/message"
|
33
|
+
NOTIFICATIONS_PROGRESS = "notifications/progress"
|
34
|
+
NOTIFICATIONS_CANCELLED = "notifications/cancelled"
|
35
|
+
|
24
36
|
class MissingRequiredCapabilityError < StandardError
|
25
37
|
attr_reader :method
|
26
38
|
attr_reader :capability
|
@@ -32,41 +44,51 @@ module MCP
|
|
32
44
|
end
|
33
45
|
end
|
34
46
|
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
47
|
+
class << self
|
48
|
+
def ensure_capability!(method, capabilities)
|
49
|
+
case method
|
50
|
+
when PROMPTS_GET, PROMPTS_LIST
|
51
|
+
require_capability!(method, capabilities, :prompts)
|
52
|
+
when NOTIFICATIONS_PROMPTS_LIST_CHANGED
|
53
|
+
require_capability!(method, capabilities, :prompts)
|
54
|
+
require_capability!(method, capabilities, :prompts, :listChanged)
|
55
|
+
when RESOURCES_LIST, RESOURCES_TEMPLATES_LIST, RESOURCES_READ
|
56
|
+
require_capability!(method, capabilities, :resources)
|
57
|
+
when NOTIFICATIONS_RESOURCES_LIST_CHANGED
|
58
|
+
require_capability!(method, capabilities, :resources)
|
59
|
+
require_capability!(method, capabilities, :resources, :listChanged)
|
60
|
+
when RESOURCES_SUBSCRIBE, RESOURCES_UNSUBSCRIBE, NOTIFICATIONS_RESOURCES_UPDATED
|
61
|
+
require_capability!(method, capabilities, :resources)
|
62
|
+
require_capability!(method, capabilities, :resources, :subscribe)
|
63
|
+
when TOOLS_CALL, TOOLS_LIST
|
64
|
+
require_capability!(method, capabilities, :tools)
|
65
|
+
when NOTIFICATIONS_TOOLS_LIST_CHANGED
|
66
|
+
require_capability!(method, capabilities, :tools)
|
67
|
+
require_capability!(method, capabilities, :tools, :listChanged)
|
68
|
+
when LOGGING_SET_LEVEL, NOTIFICATIONS_MESSAGE
|
69
|
+
require_capability!(method, capabilities, :logging)
|
70
|
+
when COMPLETION_COMPLETE
|
71
|
+
require_capability!(method, capabilities, :completions)
|
72
|
+
when ROOTS_LIST
|
73
|
+
require_capability!(method, capabilities, :roots)
|
74
|
+
when NOTIFICATIONS_ROOTS_LIST_CHANGED
|
75
|
+
require_capability!(method, capabilities, :roots)
|
76
|
+
require_capability!(method, capabilities, :roots, :listChanged)
|
77
|
+
when SAMPLING_CREATE_MESSAGE
|
78
|
+
require_capability!(method, capabilities, :sampling)
|
79
|
+
when INITIALIZE, PING, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED
|
80
|
+
# No specific capability required for initialize, ping, progress or cancelled
|
46
81
|
end
|
82
|
+
end
|
47
83
|
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
unless capabilities[:sampling]
|
57
|
-
raise MissingRequiredCapabilityError.new(method, :sampling)
|
58
|
-
end
|
59
|
-
when COMPLETION_COMPLETE
|
60
|
-
unless capabilities[:completions]
|
61
|
-
raise MissingRequiredCapabilityError.new(method, :completions)
|
62
|
-
end
|
63
|
-
when LOGGING_SET_LEVEL
|
64
|
-
# Logging is unsupported by the Server
|
65
|
-
unless capabilities[:logging]
|
66
|
-
raise MissingRequiredCapabilityError.new(method, :logging)
|
67
|
-
end
|
68
|
-
when INITIALIZE, PING
|
69
|
-
# No specific capability required for initialize or ping
|
84
|
+
private
|
85
|
+
|
86
|
+
def require_capability!(method, capabilities, *keys)
|
87
|
+
name = keys.join(".") # :resources, :subscribe -> "resources.subscribe"
|
88
|
+
has_capability = capabilities.dig(*keys)
|
89
|
+
return if has_capability
|
90
|
+
|
91
|
+
raise MissingRequiredCapabilityError.new(method, name)
|
70
92
|
end
|
71
93
|
end
|
72
94
|
end
|
data/lib/mcp/prompt.rb
CHANGED
@@ -6,20 +6,22 @@ module MCP
|
|
6
6
|
class << self
|
7
7
|
NOT_SET = Object.new
|
8
8
|
|
9
|
+
attr_reader :title_value
|
9
10
|
attr_reader :description_value
|
10
11
|
attr_reader :arguments_value
|
11
12
|
|
12
|
-
def template(args, server_context:)
|
13
|
+
def template(args, server_context: nil)
|
13
14
|
raise NotImplementedError, "Subclasses must implement template"
|
14
15
|
end
|
15
16
|
|
16
17
|
def to_h
|
17
|
-
{ name: name_value, description: description_value, arguments: arguments_value.map(&:to_h) }.compact
|
18
|
+
{ name: name_value, title: title_value, description: description_value, arguments: arguments_value.map(&:to_h) }.compact
|
18
19
|
end
|
19
20
|
|
20
21
|
def inherited(subclass)
|
21
22
|
super
|
22
23
|
subclass.instance_variable_set(:@name_value, nil)
|
24
|
+
subclass.instance_variable_set(:@title_value, nil)
|
23
25
|
subclass.instance_variable_set(:@description_value, nil)
|
24
26
|
subclass.instance_variable_set(:@arguments_value, nil)
|
25
27
|
end
|
@@ -36,6 +38,14 @@ module MCP
|
|
36
38
|
@name_value || StringUtils.handle_from_class_name(name)
|
37
39
|
end
|
38
40
|
|
41
|
+
def title(value = NOT_SET)
|
42
|
+
if value == NOT_SET
|
43
|
+
@title_value
|
44
|
+
else
|
45
|
+
@title_value = value
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
39
49
|
def description(value = NOT_SET)
|
40
50
|
if value == NOT_SET
|
41
51
|
@description_value
|
@@ -52,12 +62,13 @@ module MCP
|
|
52
62
|
end
|
53
63
|
end
|
54
64
|
|
55
|
-
def define(name: nil, description: nil, arguments: [], &block)
|
65
|
+
def define(name: nil, title: nil, description: nil, arguments: [], &block)
|
56
66
|
Class.new(self) do
|
57
67
|
prompt_name name
|
68
|
+
title title
|
58
69
|
description description
|
59
70
|
arguments arguments
|
60
|
-
define_singleton_method(:template) do |args, server_context
|
71
|
+
define_singleton_method(:template) do |args, server_context: nil|
|
61
72
|
instance_exec(args, server_context:, &block)
|
62
73
|
end
|
63
74
|
end
|
data/lib/mcp/resource.rb
CHANGED
@@ -3,21 +3,23 @@
|
|
3
3
|
|
4
4
|
module MCP
|
5
5
|
class Resource
|
6
|
-
attr_reader :uri, :name, :description, :mime_type
|
6
|
+
attr_reader :uri, :name, :title, :description, :mime_type
|
7
7
|
|
8
|
-
def initialize(uri:, name:, description
|
8
|
+
def initialize(uri:, name:, title: nil, description: nil, mime_type: nil)
|
9
9
|
@uri = uri
|
10
10
|
@name = name
|
11
|
+
@title = title
|
11
12
|
@description = description
|
12
13
|
@mime_type = mime_type
|
13
14
|
end
|
14
15
|
|
15
16
|
def to_h
|
16
17
|
{
|
17
|
-
uri:
|
18
|
-
name:
|
19
|
-
|
20
|
-
|
18
|
+
uri: uri,
|
19
|
+
name: name,
|
20
|
+
title: title,
|
21
|
+
description: description,
|
22
|
+
mimeType: mime_type,
|
21
23
|
}.compact
|
22
24
|
end
|
23
25
|
end
|
@@ -3,21 +3,23 @@
|
|
3
3
|
|
4
4
|
module MCP
|
5
5
|
class ResourceTemplate
|
6
|
-
attr_reader :uri_template, :name, :description, :mime_type
|
6
|
+
attr_reader :uri_template, :name, :title, :description, :mime_type
|
7
7
|
|
8
|
-
def initialize(uri_template:, name:, description: nil, mime_type: nil)
|
8
|
+
def initialize(uri_template:, name:, title: nil, description: nil, mime_type: nil)
|
9
9
|
@uri_template = uri_template
|
10
10
|
@name = name
|
11
|
+
@title = title
|
11
12
|
@description = description
|
12
13
|
@mime_type = mime_type
|
13
14
|
end
|
14
15
|
|
15
16
|
def to_h
|
16
17
|
{
|
17
|
-
uriTemplate:
|
18
|
-
name:
|
19
|
-
|
20
|
-
|
18
|
+
uriTemplate: uri_template,
|
19
|
+
name: name,
|
20
|
+
title: title,
|
21
|
+
description: description,
|
22
|
+
mimeType: mime_type,
|
21
23
|
}.compact
|
22
24
|
end
|
23
25
|
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MCP
|
4
|
+
class Server
|
5
|
+
class Capabilities
|
6
|
+
def initialize(capabilities_hash = nil)
|
7
|
+
@completions = nil
|
8
|
+
@experimental = nil
|
9
|
+
@logging = nil
|
10
|
+
@prompts = nil
|
11
|
+
@resources = nil
|
12
|
+
@tools = nil
|
13
|
+
|
14
|
+
if capabilities_hash
|
15
|
+
support_completions if capabilities_hash.key?(:completions)
|
16
|
+
support_experimental(capabilities_hash[:experimental]) if capabilities_hash.key?(:experimental)
|
17
|
+
support_logging if capabilities_hash.key?(:logging)
|
18
|
+
|
19
|
+
if capabilities_hash.key?(:prompts)
|
20
|
+
support_prompts
|
21
|
+
prompts_config = capabilities_hash[:prompts] || {}
|
22
|
+
support_prompts_list_changed if prompts_config[:listChanged]
|
23
|
+
end
|
24
|
+
|
25
|
+
if capabilities_hash.key?(:resources)
|
26
|
+
support_resources
|
27
|
+
resources_config = capabilities_hash[:resources] || {}
|
28
|
+
support_resources_list_changed if resources_config[:listChanged]
|
29
|
+
support_resources_subscribe if resources_config[:subscribe]
|
30
|
+
end
|
31
|
+
|
32
|
+
if capabilities_hash.key?(:tools)
|
33
|
+
support_tools
|
34
|
+
tools_config = capabilities_hash[:tools] || {}
|
35
|
+
support_tools_list_changed if tools_config[:listChanged]
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def support_completions
|
41
|
+
@completions ||= {}
|
42
|
+
end
|
43
|
+
|
44
|
+
def support_experimental(config = {})
|
45
|
+
@experimental = config || {}
|
46
|
+
end
|
47
|
+
|
48
|
+
def support_logging
|
49
|
+
@logging ||= {}
|
50
|
+
end
|
51
|
+
|
52
|
+
def support_prompts
|
53
|
+
@prompts ||= {}
|
54
|
+
end
|
55
|
+
|
56
|
+
def support_prompts_list_changed
|
57
|
+
support_prompts
|
58
|
+
@prompts[:listChanged] = true
|
59
|
+
end
|
60
|
+
|
61
|
+
def support_resources
|
62
|
+
@resources ||= {}
|
63
|
+
end
|
64
|
+
|
65
|
+
def support_resources_list_changed
|
66
|
+
support_resources
|
67
|
+
@resources[:listChanged] = true
|
68
|
+
end
|
69
|
+
|
70
|
+
def support_resources_subscribe
|
71
|
+
support_resources
|
72
|
+
@resources[:subscribe] = true
|
73
|
+
end
|
74
|
+
|
75
|
+
def support_tools
|
76
|
+
@tools ||= {}
|
77
|
+
end
|
78
|
+
|
79
|
+
def support_tools_list_changed
|
80
|
+
support_tools
|
81
|
+
@tools[:listChanged] = true
|
82
|
+
end
|
83
|
+
|
84
|
+
def to_h
|
85
|
+
{
|
86
|
+
completions: @completions,
|
87
|
+
experimental: @experimental,
|
88
|
+
logging: @logging,
|
89
|
+
prompts: @prompts,
|
90
|
+
resources: @resources,
|
91
|
+
tools: @tools,
|
92
|
+
}.compact
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../../transport"
|
4
|
+
require "json"
|
5
|
+
|
6
|
+
module MCP
|
7
|
+
class Server
|
8
|
+
module Transports
|
9
|
+
class StdioTransport < Transport
|
10
|
+
STATUS_INTERRUPTED = Signal.list["INT"] + 128
|
11
|
+
|
12
|
+
def initialize(server)
|
13
|
+
@server = server
|
14
|
+
@open = false
|
15
|
+
$stdin.set_encoding("UTF-8")
|
16
|
+
$stdout.set_encoding("UTF-8")
|
17
|
+
super
|
18
|
+
end
|
19
|
+
|
20
|
+
def open
|
21
|
+
@open = true
|
22
|
+
while @open && (line = $stdin.gets)
|
23
|
+
handle_json_request(line.strip)
|
24
|
+
end
|
25
|
+
rescue Interrupt
|
26
|
+
warn("\nExiting...")
|
27
|
+
|
28
|
+
exit(STATUS_INTERRUPTED)
|
29
|
+
end
|
30
|
+
|
31
|
+
def close
|
32
|
+
@open = false
|
33
|
+
end
|
34
|
+
|
35
|
+
def send_response(message)
|
36
|
+
json_message = message.is_a?(String) ? message : JSON.generate(message)
|
37
|
+
$stdout.puts(json_message)
|
38
|
+
$stdout.flush
|
39
|
+
end
|
40
|
+
|
41
|
+
def send_notification(method, params = nil)
|
42
|
+
notification = {
|
43
|
+
jsonrpc: "2.0",
|
44
|
+
method: method,
|
45
|
+
}
|
46
|
+
notification[:params] = params if params
|
47
|
+
|
48
|
+
send_response(notification)
|
49
|
+
true
|
50
|
+
rescue => e
|
51
|
+
MCP.configuration.exception_reporter.call(e, { error: "Failed to send notification" })
|
52
|
+
false
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,301 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../../transport"
|
4
|
+
require "json"
|
5
|
+
require "securerandom"
|
6
|
+
|
7
|
+
module MCP
|
8
|
+
class Server
|
9
|
+
module Transports
|
10
|
+
class StreamableHTTPTransport < Transport
|
11
|
+
def initialize(server)
|
12
|
+
super
|
13
|
+
# { session_id => { stream: stream_object }
|
14
|
+
@sessions = {}
|
15
|
+
@mutex = Mutex.new
|
16
|
+
end
|
17
|
+
|
18
|
+
def handle_request(request)
|
19
|
+
case request.env["REQUEST_METHOD"]
|
20
|
+
when "POST"
|
21
|
+
handle_post(request)
|
22
|
+
when "GET"
|
23
|
+
handle_get(request)
|
24
|
+
when "DELETE"
|
25
|
+
handle_delete(request)
|
26
|
+
else
|
27
|
+
[405, { "Content-Type" => "application/json" }, [{ error: "Method not allowed" }.to_json]]
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def close
|
32
|
+
@mutex.synchronize do
|
33
|
+
@sessions.each_key { |session_id| cleanup_session_unsafe(session_id) }
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def send_notification(method, params = nil, session_id: nil)
|
38
|
+
notification = {
|
39
|
+
jsonrpc: "2.0",
|
40
|
+
method:,
|
41
|
+
}
|
42
|
+
notification[:params] = params if params
|
43
|
+
|
44
|
+
@mutex.synchronize do
|
45
|
+
if session_id
|
46
|
+
# Send to specific session
|
47
|
+
session = @sessions[session_id]
|
48
|
+
return false unless session && session[:stream]
|
49
|
+
|
50
|
+
begin
|
51
|
+
send_to_stream(session[:stream], notification)
|
52
|
+
true
|
53
|
+
rescue IOError, Errno::EPIPE => e
|
54
|
+
MCP.configuration.exception_reporter.call(
|
55
|
+
e,
|
56
|
+
{ session_id: session_id, error: "Failed to send notification" },
|
57
|
+
)
|
58
|
+
cleanup_session_unsafe(session_id)
|
59
|
+
false
|
60
|
+
end
|
61
|
+
else
|
62
|
+
# Broadcast to all connected SSE sessions
|
63
|
+
sent_count = 0
|
64
|
+
failed_sessions = []
|
65
|
+
|
66
|
+
@sessions.each do |sid, session|
|
67
|
+
next unless session[:stream]
|
68
|
+
|
69
|
+
begin
|
70
|
+
send_to_stream(session[:stream], notification)
|
71
|
+
sent_count += 1
|
72
|
+
rescue IOError, Errno::EPIPE => e
|
73
|
+
MCP.configuration.exception_reporter.call(
|
74
|
+
e,
|
75
|
+
{ session_id: sid, error: "Failed to send notification" },
|
76
|
+
)
|
77
|
+
failed_sessions << sid
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
# Clean up failed sessions
|
82
|
+
failed_sessions.each { |sid| cleanup_session_unsafe(sid) }
|
83
|
+
|
84
|
+
sent_count
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
|
91
|
+
def send_to_stream(stream, data)
|
92
|
+
message = data.is_a?(String) ? data : data.to_json
|
93
|
+
stream.write("data: #{message}\n\n")
|
94
|
+
stream.flush if stream.respond_to?(:flush)
|
95
|
+
end
|
96
|
+
|
97
|
+
def send_ping_to_stream(stream)
|
98
|
+
stream.write(": ping #{Time.now.iso8601}\n\n")
|
99
|
+
stream.flush if stream.respond_to?(:flush)
|
100
|
+
end
|
101
|
+
|
102
|
+
def handle_post(request)
|
103
|
+
body_string = request.body.read
|
104
|
+
session_id = extract_session_id(request)
|
105
|
+
|
106
|
+
body = parse_request_body(body_string)
|
107
|
+
return body unless body.is_a?(Hash) # Error response
|
108
|
+
|
109
|
+
if body["method"] == "initialize"
|
110
|
+
handle_initialization(body_string, body)
|
111
|
+
elsif body["method"] == MCP::Methods::NOTIFICATIONS_INITIALIZED
|
112
|
+
handle_notification_initialized
|
113
|
+
else
|
114
|
+
handle_regular_request(body_string, session_id)
|
115
|
+
end
|
116
|
+
rescue StandardError => e
|
117
|
+
MCP.configuration.exception_reporter.call(e, { request: body_string })
|
118
|
+
[500, { "Content-Type" => "application/json" }, [{ error: "Internal server error" }.to_json]]
|
119
|
+
end
|
120
|
+
|
121
|
+
def handle_get(request)
|
122
|
+
session_id = extract_session_id(request)
|
123
|
+
|
124
|
+
return missing_session_id_response unless session_id
|
125
|
+
return session_not_found_response unless session_exists?(session_id)
|
126
|
+
|
127
|
+
setup_sse_stream(session_id)
|
128
|
+
end
|
129
|
+
|
130
|
+
def handle_delete(request)
|
131
|
+
session_id = request.env["HTTP_MCP_SESSION_ID"]
|
132
|
+
|
133
|
+
return [
|
134
|
+
400,
|
135
|
+
{ "Content-Type" => "application/json" },
|
136
|
+
[{ error: "Missing session ID" }.to_json],
|
137
|
+
] unless session_id
|
138
|
+
|
139
|
+
cleanup_session(session_id)
|
140
|
+
[200, { "Content-Type" => "application/json" }, [{ success: true }.to_json]]
|
141
|
+
end
|
142
|
+
|
143
|
+
def cleanup_session(session_id)
|
144
|
+
@mutex.synchronize do
|
145
|
+
cleanup_session_unsafe(session_id)
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
def cleanup_session_unsafe(session_id)
|
150
|
+
session = @sessions[session_id]
|
151
|
+
return unless session
|
152
|
+
|
153
|
+
begin
|
154
|
+
session[:stream]&.close
|
155
|
+
rescue
|
156
|
+
nil
|
157
|
+
end
|
158
|
+
@sessions.delete(session_id)
|
159
|
+
end
|
160
|
+
|
161
|
+
def extract_session_id(request)
|
162
|
+
request.env["HTTP_MCP_SESSION_ID"]
|
163
|
+
end
|
164
|
+
|
165
|
+
def parse_request_body(body_string)
|
166
|
+
JSON.parse(body_string)
|
167
|
+
rescue JSON::ParserError, TypeError
|
168
|
+
[400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
|
169
|
+
end
|
170
|
+
|
171
|
+
def handle_initialization(body_string, body)
|
172
|
+
session_id = SecureRandom.uuid
|
173
|
+
|
174
|
+
@mutex.synchronize do
|
175
|
+
@sessions[session_id] = {
|
176
|
+
stream: nil,
|
177
|
+
}
|
178
|
+
end
|
179
|
+
|
180
|
+
response = @server.handle_json(body_string)
|
181
|
+
|
182
|
+
headers = {
|
183
|
+
"Content-Type" => "application/json",
|
184
|
+
"Mcp-Session-Id" => session_id,
|
185
|
+
}
|
186
|
+
|
187
|
+
[200, headers, [response]]
|
188
|
+
end
|
189
|
+
|
190
|
+
def handle_notification_initialized
|
191
|
+
[202, {}, []]
|
192
|
+
end
|
193
|
+
|
194
|
+
def handle_regular_request(body_string, session_id)
|
195
|
+
# If session ID is provided, but not in the sessions hash, return an error
|
196
|
+
if session_id && !@sessions.key?(session_id)
|
197
|
+
return [400, { "Content-Type" => "application/json" }, [{ error: "Invalid session ID" }.to_json]]
|
198
|
+
end
|
199
|
+
|
200
|
+
response = @server.handle_json(body_string)
|
201
|
+
stream = get_session_stream(session_id) if session_id
|
202
|
+
|
203
|
+
if stream
|
204
|
+
send_response_to_stream(stream, response, session_id)
|
205
|
+
else
|
206
|
+
[200, { "Content-Type" => "application/json" }, [response]]
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
def get_session_stream(session_id)
|
211
|
+
@mutex.synchronize { @sessions[session_id]&.fetch(:stream, nil) }
|
212
|
+
end
|
213
|
+
|
214
|
+
def send_response_to_stream(stream, response, session_id)
|
215
|
+
message = JSON.parse(response)
|
216
|
+
send_to_stream(stream, message)
|
217
|
+
[200, { "Content-Type" => "application/json" }, [{ accepted: true }.to_json]]
|
218
|
+
rescue IOError, Errno::EPIPE => e
|
219
|
+
MCP.configuration.exception_reporter.call(
|
220
|
+
e,
|
221
|
+
{ session_id: session_id, error: "Stream closed during response" },
|
222
|
+
)
|
223
|
+
cleanup_session(session_id)
|
224
|
+
[200, { "Content-Type" => "application/json" }, [response]]
|
225
|
+
end
|
226
|
+
|
227
|
+
def session_exists?(session_id)
|
228
|
+
@mutex.synchronize { @sessions.key?(session_id) }
|
229
|
+
end
|
230
|
+
|
231
|
+
def missing_session_id_response
|
232
|
+
[400, { "Content-Type" => "application/json" }, [{ error: "Missing session ID" }.to_json]]
|
233
|
+
end
|
234
|
+
|
235
|
+
def session_not_found_response
|
236
|
+
[404, { "Content-Type" => "application/json" }, [{ error: "Session not found" }.to_json]]
|
237
|
+
end
|
238
|
+
|
239
|
+
def setup_sse_stream(session_id)
|
240
|
+
body = create_sse_body(session_id)
|
241
|
+
|
242
|
+
headers = {
|
243
|
+
"Content-Type" => "text/event-stream",
|
244
|
+
"Cache-Control" => "no-cache",
|
245
|
+
"Connection" => "keep-alive",
|
246
|
+
}
|
247
|
+
|
248
|
+
[200, headers, body]
|
249
|
+
end
|
250
|
+
|
251
|
+
def create_sse_body(session_id)
|
252
|
+
proc do |stream|
|
253
|
+
store_stream_for_session(session_id, stream)
|
254
|
+
start_keepalive_thread(session_id)
|
255
|
+
end
|
256
|
+
end
|
257
|
+
|
258
|
+
def store_stream_for_session(session_id, stream)
|
259
|
+
@mutex.synchronize do
|
260
|
+
if @sessions[session_id]
|
261
|
+
@sessions[session_id][:stream] = stream
|
262
|
+
else
|
263
|
+
stream.close
|
264
|
+
end
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
def start_keepalive_thread(session_id)
|
269
|
+
Thread.new do
|
270
|
+
while session_active_with_stream?(session_id)
|
271
|
+
sleep(30)
|
272
|
+
send_keepalive_ping(session_id)
|
273
|
+
end
|
274
|
+
rescue StandardError => e
|
275
|
+
MCP.configuration.exception_reporter.call(e, { session_id: session_id })
|
276
|
+
ensure
|
277
|
+
cleanup_session(session_id)
|
278
|
+
end
|
279
|
+
end
|
280
|
+
|
281
|
+
def session_active_with_stream?(session_id)
|
282
|
+
@mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:stream] }
|
283
|
+
end
|
284
|
+
|
285
|
+
def send_keepalive_ping(session_id)
|
286
|
+
@mutex.synchronize do
|
287
|
+
if @sessions[session_id] && @sessions[session_id][:stream]
|
288
|
+
send_ping_to_stream(@sessions[session_id][:stream])
|
289
|
+
end
|
290
|
+
end
|
291
|
+
rescue IOError, Errno::EPIPE => e
|
292
|
+
MCP.configuration.exception_reporter.call(
|
293
|
+
e,
|
294
|
+
{ session_id: session_id, error: "Stream closed" },
|
295
|
+
)
|
296
|
+
raise # Re-raise to exit the keepalive loop
|
297
|
+
end
|
298
|
+
end
|
299
|
+
end
|
300
|
+
end
|
301
|
+
end
|