model-context-protocol-rb 0.3.4 → 0.5.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 -1
- data/README.md +886 -196
- data/lib/model_context_protocol/server/cancellable.rb +54 -0
- data/lib/model_context_protocol/server/configuration.rb +80 -8
- data/lib/model_context_protocol/server/content.rb +321 -0
- data/lib/model_context_protocol/server/content_helpers.rb +84 -0
- data/lib/model_context_protocol/server/pagination.rb +71 -0
- data/lib/model_context_protocol/server/progressable.rb +72 -0
- data/lib/model_context_protocol/server/prompt.rb +108 -14
- data/lib/model_context_protocol/server/redis_client_proxy.rb +134 -0
- data/lib/model_context_protocol/server/redis_config.rb +108 -0
- data/lib/model_context_protocol/server/redis_pool_manager.rb +110 -0
- data/lib/model_context_protocol/server/registry.rb +94 -18
- data/lib/model_context_protocol/server/resource.rb +98 -25
- data/lib/model_context_protocol/server/resource_template.rb +26 -13
- data/lib/model_context_protocol/server/router.rb +36 -3
- data/lib/model_context_protocol/server/stdio_transport/request_store.rb +102 -0
- data/lib/model_context_protocol/server/stdio_transport.rb +31 -6
- data/lib/model_context_protocol/server/streamable_http_transport/event_counter.rb +35 -0
- data/lib/model_context_protocol/server/streamable_http_transport/message_poller.rb +101 -0
- data/lib/model_context_protocol/server/streamable_http_transport/notification_queue.rb +80 -0
- data/lib/model_context_protocol/server/streamable_http_transport/request_store.rb +224 -0
- data/lib/model_context_protocol/server/streamable_http_transport/session_message_queue.rb +120 -0
- data/lib/model_context_protocol/server/{session_store.rb → streamable_http_transport/session_store.rb} +30 -16
- data/lib/model_context_protocol/server/streamable_http_transport/stream_registry.rb +119 -0
- data/lib/model_context_protocol/server/streamable_http_transport.rb +352 -112
- data/lib/model_context_protocol/server/tool.rb +79 -53
- data/lib/model_context_protocol/server.rb +124 -21
- data/lib/model_context_protocol/version.rb +1 -1
- data/tasks/mcp.rake +28 -2
- data/tasks/templates/dev-http.erb +288 -0
- data/tasks/templates/dev.erb +7 -1
- metadata +61 -3
@@ -39,8 +39,8 @@ module ModelContextProtocol
|
|
39
39
|
end
|
40
40
|
|
41
41
|
def register(klass)
|
42
|
-
|
43
|
-
entry = {klass: klass}.merge(
|
42
|
+
definition = klass.definition
|
43
|
+
entry = {klass: klass}.merge(definition)
|
44
44
|
|
45
45
|
case klass.ancestors
|
46
46
|
when ->(ancestors) { ancestors.include?(ModelContextProtocol::Server::Prompt) }
|
@@ -74,45 +74,121 @@ module ModelContextProtocol
|
|
74
74
|
find_by_name(@tools, name)
|
75
75
|
end
|
76
76
|
|
77
|
-
def prompts_data
|
78
|
-
|
77
|
+
def prompts_data(cursor: nil, page_size: nil, cursor_ttl: nil)
|
78
|
+
items = @prompts.map { |entry| entry.except(:klass) }
|
79
|
+
|
80
|
+
if cursor || page_size
|
81
|
+
paginated = Server::Pagination.paginate(
|
82
|
+
items,
|
83
|
+
cursor: cursor,
|
84
|
+
page_size: page_size || 100,
|
85
|
+
cursor_ttl: cursor_ttl
|
86
|
+
)
|
87
|
+
|
88
|
+
PromptsData[prompts: paginated.items, next_cursor: paginated.next_cursor]
|
89
|
+
else
|
90
|
+
PromptsData[prompts: items]
|
91
|
+
end
|
79
92
|
end
|
80
93
|
|
81
|
-
def resources_data
|
82
|
-
|
94
|
+
def resources_data(cursor: nil, page_size: nil, cursor_ttl: nil)
|
95
|
+
items = @resources.map { |entry| entry.except(:klass) }
|
96
|
+
|
97
|
+
if cursor || page_size
|
98
|
+
paginated = Server::Pagination.paginate(
|
99
|
+
items,
|
100
|
+
cursor: cursor,
|
101
|
+
page_size: page_size || 100,
|
102
|
+
cursor_ttl: cursor_ttl
|
103
|
+
)
|
104
|
+
|
105
|
+
ResourcesData[resources: paginated.items, next_cursor: paginated.next_cursor]
|
106
|
+
else
|
107
|
+
ResourcesData[resources: items]
|
108
|
+
end
|
83
109
|
end
|
84
110
|
|
85
|
-
def resource_templates_data
|
86
|
-
|
111
|
+
def resource_templates_data(cursor: nil, page_size: nil, cursor_ttl: nil)
|
112
|
+
items = @resource_templates.map { |entry| entry.except(:klass, :completions) }
|
113
|
+
|
114
|
+
if cursor || page_size
|
115
|
+
paginated = Server::Pagination.paginate(
|
116
|
+
items,
|
117
|
+
cursor: cursor,
|
118
|
+
page_size: page_size || 100,
|
119
|
+
cursor_ttl: cursor_ttl
|
120
|
+
)
|
121
|
+
|
122
|
+
ResourceTemplatesData[resource_templates: paginated.items, next_cursor: paginated.next_cursor]
|
123
|
+
else
|
124
|
+
ResourceTemplatesData[resource_templates: items]
|
125
|
+
end
|
87
126
|
end
|
88
127
|
|
89
|
-
def tools_data
|
90
|
-
|
128
|
+
def tools_data(cursor: nil, page_size: nil, cursor_ttl: nil)
|
129
|
+
items = @tools.map { |entry| entry.except(:klass) }
|
130
|
+
|
131
|
+
if cursor || page_size
|
132
|
+
paginated = Server::Pagination.paginate(
|
133
|
+
items,
|
134
|
+
cursor: cursor,
|
135
|
+
page_size: page_size || 100,
|
136
|
+
cursor_ttl: cursor_ttl
|
137
|
+
)
|
138
|
+
|
139
|
+
ToolsData[tools: paginated.items, next_cursor: paginated.next_cursor]
|
140
|
+
else
|
141
|
+
ToolsData[tools: items]
|
142
|
+
end
|
91
143
|
end
|
92
144
|
|
93
145
|
private
|
94
146
|
|
95
|
-
PromptsData = Data.define(:prompts) do
|
147
|
+
PromptsData = Data.define(:prompts, :next_cursor) do
|
148
|
+
def initialize(prompts:, next_cursor: nil)
|
149
|
+
super
|
150
|
+
end
|
151
|
+
|
96
152
|
def serialized
|
97
|
-
{prompts:}
|
153
|
+
result = {prompts:}
|
154
|
+
result[:nextCursor] = next_cursor if next_cursor
|
155
|
+
result
|
98
156
|
end
|
99
157
|
end
|
100
158
|
|
101
|
-
ResourcesData = Data.define(:resources) do
|
159
|
+
ResourcesData = Data.define(:resources, :next_cursor) do
|
160
|
+
def initialize(resources:, next_cursor: nil)
|
161
|
+
super
|
162
|
+
end
|
163
|
+
|
102
164
|
def serialized
|
103
|
-
{resources:}
|
165
|
+
result = {resources:}
|
166
|
+
result[:nextCursor] = next_cursor if next_cursor
|
167
|
+
result
|
104
168
|
end
|
105
169
|
end
|
106
170
|
|
107
|
-
ResourceTemplatesData = Data.define(:resource_templates) do
|
171
|
+
ResourceTemplatesData = Data.define(:resource_templates, :next_cursor) do
|
172
|
+
def initialize(resource_templates:, next_cursor: nil)
|
173
|
+
super
|
174
|
+
end
|
175
|
+
|
108
176
|
def serialized
|
109
|
-
{resourceTemplates: resource_templates}
|
177
|
+
result = {resourceTemplates: resource_templates}
|
178
|
+
result[:nextCursor] = next_cursor if next_cursor
|
179
|
+
result
|
110
180
|
end
|
111
181
|
end
|
112
182
|
|
113
|
-
ToolsData = Data.define(:tools) do
|
183
|
+
ToolsData = Data.define(:tools, :next_cursor) do
|
184
|
+
def initialize(tools:, next_cursor: nil)
|
185
|
+
super
|
186
|
+
end
|
187
|
+
|
114
188
|
def serialized
|
115
|
-
{tools:}
|
189
|
+
result = {tools:}
|
190
|
+
result[:nextCursor] = next_cursor if next_cursor
|
191
|
+
result
|
116
192
|
end
|
117
193
|
end
|
118
194
|
|
@@ -1,12 +1,13 @@
|
|
1
1
|
module ModelContextProtocol
|
2
2
|
class Server::Resource
|
3
|
-
|
3
|
+
include ModelContextProtocol::Server::Cancellable
|
4
|
+
include ModelContextProtocol::Server::Progressable
|
4
5
|
|
5
|
-
|
6
|
+
attr_reader :mime_type, :uri
|
7
|
+
|
8
|
+
def initialize
|
6
9
|
@mime_type = self.class.mime_type
|
7
10
|
@uri = self.class.uri
|
8
|
-
@context = context
|
9
|
-
@logger = logger
|
10
11
|
end
|
11
12
|
|
12
13
|
def call
|
@@ -15,59 +16,76 @@ module ModelContextProtocol
|
|
15
16
|
|
16
17
|
TextResponse = Data.define(:resource, :text) do
|
17
18
|
def serialized
|
18
|
-
|
19
|
+
content = {mimeType: resource.mime_type, text:, uri: resource.uri}
|
20
|
+
content[:title] = resource.class.title if resource.class.title
|
21
|
+
annotations = resource.class.annotations&.serialized
|
22
|
+
content[:annotations] = annotations if annotations
|
23
|
+
{contents: [content]}
|
19
24
|
end
|
20
25
|
end
|
21
26
|
private_constant :TextResponse
|
22
27
|
|
23
28
|
BinaryResponse = Data.define(:blob, :resource) do
|
24
29
|
def serialized
|
25
|
-
|
30
|
+
content = {blob:, mimeType: resource.mime_type, uri: resource.uri}
|
31
|
+
content[:title] = resource.class.title if resource.class.title
|
32
|
+
annotations = resource.class.annotations&.serialized
|
33
|
+
content[:annotations] = annotations if annotations
|
34
|
+
{contents: [content]}
|
26
35
|
end
|
27
36
|
end
|
28
37
|
private_constant :BinaryResponse
|
29
38
|
|
30
|
-
private def respond_with(
|
31
|
-
case [
|
32
|
-
in [
|
39
|
+
private def respond_with(**kwargs)
|
40
|
+
case [kwargs]
|
41
|
+
in [{text:}]
|
33
42
|
TextResponse[resource: self, text:]
|
34
|
-
in [
|
35
|
-
BinaryResponse[blob
|
43
|
+
in [{binary:}]
|
44
|
+
BinaryResponse[blob: binary, resource: self]
|
36
45
|
else
|
37
|
-
raise ModelContextProtocol::Server::ResponseArgumentsError, "Invalid arguments: #{
|
46
|
+
raise ModelContextProtocol::Server::ResponseArgumentsError, "Invalid arguments: #{options}"
|
38
47
|
end
|
39
48
|
end
|
40
49
|
|
41
50
|
class << self
|
42
|
-
attr_reader :name, :description, :mime_type, :uri
|
51
|
+
attr_reader :name, :description, :title, :mime_type, :uri, :annotations
|
43
52
|
|
44
|
-
def
|
45
|
-
|
46
|
-
|
53
|
+
def define(&block)
|
54
|
+
definition_dsl = DefinitionDSL.new
|
55
|
+
definition_dsl.instance_eval(&block)
|
47
56
|
|
48
|
-
@name =
|
49
|
-
@description =
|
50
|
-
@
|
51
|
-
@
|
57
|
+
@name = definition_dsl.name
|
58
|
+
@description = definition_dsl.description
|
59
|
+
@title = definition_dsl.title
|
60
|
+
@mime_type = definition_dsl.mime_type
|
61
|
+
@uri = definition_dsl.uri
|
62
|
+
@annotations = definition_dsl.defined_annotations
|
52
63
|
end
|
53
64
|
|
54
65
|
def inherited(subclass)
|
55
66
|
subclass.instance_variable_set(:@name, @name)
|
56
67
|
subclass.instance_variable_set(:@description, @description)
|
68
|
+
subclass.instance_variable_set(:@title, @title)
|
57
69
|
subclass.instance_variable_set(:@mime_type, @mime_type)
|
58
70
|
subclass.instance_variable_set(:@uri, @uri)
|
71
|
+
subclass.instance_variable_set(:@annotations, @annotations&.dup)
|
59
72
|
end
|
60
73
|
|
61
|
-
def call
|
62
|
-
new
|
74
|
+
def call
|
75
|
+
new.call
|
63
76
|
end
|
64
77
|
|
65
|
-
def
|
66
|
-
{name: @name, description: @description, mimeType: @mime_type, uri: @uri}
|
78
|
+
def definition
|
79
|
+
result = {name: @name, description: @description, mimeType: @mime_type, uri: @uri}
|
80
|
+
result[:title] = @title if @title
|
81
|
+
result[:annotations] = @annotations.serialized if @annotations
|
82
|
+
result
|
67
83
|
end
|
68
84
|
end
|
69
85
|
|
70
|
-
class
|
86
|
+
class DefinitionDSL
|
87
|
+
attr_reader :defined_annotations
|
88
|
+
|
71
89
|
def name(value = nil)
|
72
90
|
@name = value if value
|
73
91
|
@name
|
@@ -78,6 +96,11 @@ module ModelContextProtocol
|
|
78
96
|
@description
|
79
97
|
end
|
80
98
|
|
99
|
+
def title(value = nil)
|
100
|
+
@title = value if value
|
101
|
+
@title
|
102
|
+
end
|
103
|
+
|
81
104
|
def mime_type(value = nil)
|
82
105
|
@mime_type = value if value
|
83
106
|
@mime_type
|
@@ -87,6 +110,56 @@ module ModelContextProtocol
|
|
87
110
|
@uri = value if value
|
88
111
|
@uri
|
89
112
|
end
|
113
|
+
|
114
|
+
def annotations(&block)
|
115
|
+
@defined_annotations = AnnotationsDSL.new
|
116
|
+
@defined_annotations.instance_eval(&block)
|
117
|
+
@defined_annotations
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
class AnnotationsDSL
|
122
|
+
VALID_AUDIENCE_VALUES = [:user, :assistant].freeze
|
123
|
+
|
124
|
+
def initialize
|
125
|
+
@audience = nil
|
126
|
+
@priority = nil
|
127
|
+
@last_modified = nil
|
128
|
+
end
|
129
|
+
|
130
|
+
def audience(value)
|
131
|
+
normalized_value = Array(value).map(&:to_sym)
|
132
|
+
invalid_values = normalized_value - VALID_AUDIENCE_VALUES
|
133
|
+
unless invalid_values.empty?
|
134
|
+
raise ArgumentError, "Invalid audience values: #{invalid_values.join(", ")}. Valid values are: #{VALID_AUDIENCE_VALUES.join(", ")}"
|
135
|
+
end
|
136
|
+
@audience = normalized_value.map(&:to_s)
|
137
|
+
end
|
138
|
+
|
139
|
+
def priority(value)
|
140
|
+
unless value.is_a?(Numeric) && value >= 0.0 && value <= 1.0
|
141
|
+
raise ArgumentError, "Priority must be a number between 0.0 and 1.0, got: #{value}"
|
142
|
+
end
|
143
|
+
@priority = value.to_f
|
144
|
+
end
|
145
|
+
|
146
|
+
def last_modified(value)
|
147
|
+
# Validate ISO 8601 format
|
148
|
+
begin
|
149
|
+
Time.iso8601(value)
|
150
|
+
rescue ArgumentError
|
151
|
+
raise ArgumentError, "lastModified must be in ISO 8601 format (e.g., '2025-01-12T15:00:58Z'), got: #{value}"
|
152
|
+
end
|
153
|
+
@last_modified = value
|
154
|
+
end
|
155
|
+
|
156
|
+
def serialized
|
157
|
+
result = {}
|
158
|
+
result[:audience] = @audience if @audience
|
159
|
+
result[:priority] = @priority if @priority
|
160
|
+
result[:lastModified] = @last_modified if @last_modified
|
161
|
+
result.empty? ? nil : result
|
162
|
+
end
|
90
163
|
end
|
91
164
|
end
|
92
165
|
end
|
@@ -3,15 +3,15 @@ module ModelContextProtocol
|
|
3
3
|
class << self
|
4
4
|
attr_reader :name, :description, :mime_type, :uri_template, :completions
|
5
5
|
|
6
|
-
def
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
@name =
|
11
|
-
@description =
|
12
|
-
@mime_type =
|
13
|
-
@uri_template =
|
14
|
-
@completions =
|
6
|
+
def define(&block)
|
7
|
+
definition_dsl = DefinitionDSL.new
|
8
|
+
definition_dsl.instance_eval(&block)
|
9
|
+
|
10
|
+
@name = definition_dsl.name
|
11
|
+
@description = definition_dsl.description
|
12
|
+
@mime_type = definition_dsl.mime_type
|
13
|
+
@uri_template = definition_dsl.uri_template
|
14
|
+
@completions = definition_dsl.completions
|
15
15
|
end
|
16
16
|
|
17
17
|
def inherited(subclass)
|
@@ -32,7 +32,7 @@ module ModelContextProtocol
|
|
32
32
|
completion.call(param_name.to_s, value)
|
33
33
|
end
|
34
34
|
|
35
|
-
def
|
35
|
+
def definition
|
36
36
|
{
|
37
37
|
name: @name,
|
38
38
|
description: @description,
|
@@ -43,7 +43,7 @@ module ModelContextProtocol
|
|
43
43
|
end
|
44
44
|
end
|
45
45
|
|
46
|
-
class
|
46
|
+
class DefinitionDSL
|
47
47
|
attr_reader :completions
|
48
48
|
|
49
49
|
def initialize
|
@@ -85,8 +85,21 @@ module ModelContextProtocol
|
|
85
85
|
@completions = {}
|
86
86
|
end
|
87
87
|
|
88
|
-
def completion(param_name,
|
89
|
-
@completions[param_name.to_s] =
|
88
|
+
def completion(param_name, completion_class_or_values)
|
89
|
+
@completions[param_name.to_s] = if completion_class_or_values.is_a?(Array)
|
90
|
+
create_array_completion(completion_class_or_values)
|
91
|
+
else
|
92
|
+
completion_class_or_values
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
private
|
97
|
+
|
98
|
+
def create_array_completion(values)
|
99
|
+
ModelContextProtocol::Server::Completion.define do
|
100
|
+
filtered_values = values.grep(/#{argument_value}/)
|
101
|
+
respond_with values: filtered_values
|
102
|
+
end
|
90
103
|
end
|
91
104
|
end
|
92
105
|
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
require_relative "cancellable"
|
2
|
+
|
1
3
|
module ModelContextProtocol
|
2
4
|
class Server::Router
|
3
5
|
# Raised when an invalid method is provided.
|
@@ -12,14 +14,45 @@ module ModelContextProtocol
|
|
12
14
|
@handlers[method] = handler
|
13
15
|
end
|
14
16
|
|
15
|
-
|
17
|
+
# Route a message to its handler with request tracking support
|
18
|
+
#
|
19
|
+
# @param message [Hash] the JSON-RPC message
|
20
|
+
# @param request_store [Object] the request store for tracking cancellation
|
21
|
+
# @param session_id [String, nil] the session ID for HTTP transport
|
22
|
+
# @param transport [Object, nil] the transport for sending notifications
|
23
|
+
# @return [Object] the handler result, or nil if cancelled
|
24
|
+
def route(message, request_store: nil, session_id: nil, transport: nil)
|
16
25
|
method = message["method"]
|
17
26
|
handler = @handlers[method]
|
18
27
|
raise MethodNotFoundError, "Method not found: #{method}" unless handler
|
19
28
|
|
20
|
-
|
21
|
-
|
29
|
+
request_id = message["id"]
|
30
|
+
progress_token = message.dig("params", "_meta", "progressToken")
|
31
|
+
|
32
|
+
if request_id && request_store
|
33
|
+
request_store.register_request(request_id, session_id)
|
34
|
+
end
|
35
|
+
|
36
|
+
result = nil
|
37
|
+
begin
|
38
|
+
with_environment(@configuration&.environment_variables) do
|
39
|
+
context = {request_id:, request_store:, session_id:, progress_token:, transport:}
|
40
|
+
|
41
|
+
Thread.current[:mcp_context] = context
|
42
|
+
|
43
|
+
result = handler.call(message)
|
44
|
+
end
|
45
|
+
rescue Server::Cancellable::CancellationError
|
46
|
+
return nil
|
47
|
+
ensure
|
48
|
+
if request_id && request_store
|
49
|
+
request_store.unregister_request(request_id)
|
50
|
+
end
|
51
|
+
|
52
|
+
Thread.current[:mcp_context] = nil
|
22
53
|
end
|
54
|
+
|
55
|
+
result
|
23
56
|
end
|
24
57
|
|
25
58
|
private
|
@@ -0,0 +1,102 @@
|
|
1
|
+
module ModelContextProtocol
|
2
|
+
class Server::StdioTransport
|
3
|
+
# Thread-safe in-memory storage for tracking active requests and their cancellation status.
|
4
|
+
# This store is used by StdioTransport to manage request lifecycle and handle cancellation.
|
5
|
+
class RequestStore
|
6
|
+
def initialize
|
7
|
+
@mutex = Mutex.new
|
8
|
+
@requests = {}
|
9
|
+
end
|
10
|
+
|
11
|
+
# Register a new request with its associated thread
|
12
|
+
#
|
13
|
+
# @param request_id [String] the unique request identifier
|
14
|
+
# @param thread [Thread] the thread processing this request (defaults to current thread)
|
15
|
+
# @return [void]
|
16
|
+
def register_request(request_id, thread = Thread.current)
|
17
|
+
@mutex.synchronize do
|
18
|
+
@requests[request_id] = {
|
19
|
+
thread:,
|
20
|
+
cancelled: false,
|
21
|
+
started_at: Time.now
|
22
|
+
}
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Mark a request as cancelled
|
27
|
+
#
|
28
|
+
# @param request_id [String] the unique request identifier
|
29
|
+
# @return [Boolean] true if request was found and marked cancelled, false otherwise
|
30
|
+
def mark_cancelled(request_id)
|
31
|
+
@mutex.synchronize do
|
32
|
+
if (request = @requests[request_id])
|
33
|
+
request[:cancelled] = true
|
34
|
+
return true
|
35
|
+
end
|
36
|
+
false
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# Check if a request has been cancelled
|
41
|
+
#
|
42
|
+
# @param request_id [String] the unique request identifier
|
43
|
+
# @return [Boolean] true if the request is cancelled, false otherwise
|
44
|
+
def cancelled?(request_id)
|
45
|
+
@mutex.synchronize do
|
46
|
+
@requests[request_id]&.fetch(:cancelled, false) || false
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# Unregister a request (typically called when request completes)
|
51
|
+
#
|
52
|
+
# @param request_id [String] the unique request identifier
|
53
|
+
# @return [Hash, nil] the removed request data, or nil if not found
|
54
|
+
def unregister_request(request_id)
|
55
|
+
@mutex.synchronize do
|
56
|
+
@requests.delete(request_id)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Get information about a specific request
|
61
|
+
#
|
62
|
+
# @param request_id [String] the unique request identifier
|
63
|
+
# @return [Hash, nil] request information or nil if not found
|
64
|
+
def get_request(request_id)
|
65
|
+
@mutex.synchronize do
|
66
|
+
@requests[request_id]&.dup
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
# Get all active request IDs
|
71
|
+
#
|
72
|
+
# @return [Array<String>] list of active request IDs
|
73
|
+
def active_requests
|
74
|
+
@mutex.synchronize do
|
75
|
+
@requests.keys.dup
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# Clean up old requests (useful for preventing memory leaks)
|
80
|
+
#
|
81
|
+
# @param max_age_seconds [Integer] maximum age of requests to keep
|
82
|
+
# @return [Array<String>] list of cleaned up request IDs
|
83
|
+
def cleanup_old_requests(max_age_seconds = 300)
|
84
|
+
cutoff_time = Time.now - max_age_seconds
|
85
|
+
removed_ids = []
|
86
|
+
|
87
|
+
@mutex.synchronize do
|
88
|
+
@requests.delete_if do |request_id, data|
|
89
|
+
if data[:started_at] < cutoff_time
|
90
|
+
removed_ids << request_id
|
91
|
+
true
|
92
|
+
else
|
93
|
+
false
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
removed_ids
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
require_relative "stdio_transport/request_store"
|
2
|
+
|
1
3
|
module ModelContextProtocol
|
2
4
|
class Server::StdioTransport
|
3
5
|
Response = Data.define(:id, :result) do
|
@@ -12,15 +14,15 @@ module ModelContextProtocol
|
|
12
14
|
end
|
13
15
|
end
|
14
16
|
|
15
|
-
attr_reader :router, :configuration
|
17
|
+
attr_reader :router, :configuration, :request_store
|
16
18
|
|
17
19
|
def initialize(router:, configuration:)
|
18
20
|
@router = router
|
19
21
|
@configuration = configuration
|
22
|
+
@request_store = RequestStore.new
|
20
23
|
end
|
21
24
|
|
22
25
|
def handle
|
23
|
-
# Connect logger to transport
|
24
26
|
@configuration.logger.connect_transport(self)
|
25
27
|
|
26
28
|
loop do
|
@@ -29,10 +31,19 @@ module ModelContextProtocol
|
|
29
31
|
|
30
32
|
begin
|
31
33
|
message = JSON.parse(line.chomp)
|
32
|
-
next if message["method"].start_with?("notifications")
|
33
34
|
|
34
|
-
|
35
|
-
|
35
|
+
if message["method"] == "notifications/cancelled"
|
36
|
+
handle_cancellation(message)
|
37
|
+
next
|
38
|
+
end
|
39
|
+
|
40
|
+
next if message["method"]&.start_with?("notifications/")
|
41
|
+
|
42
|
+
result = router.route(message, request_store: @request_store, transport: self)
|
43
|
+
|
44
|
+
if result
|
45
|
+
send_message(Response[id: message["id"], result: result.serialized])
|
46
|
+
end
|
36
47
|
rescue ModelContextProtocol::Server::ParameterValidationError => validation_error
|
37
48
|
@configuration.logger.error("Validation error", error: validation_error.message)
|
38
49
|
send_message(
|
@@ -61,12 +72,26 @@ module ModelContextProtocol
|
|
61
72
|
$stdout.puts(JSON.generate(notification))
|
62
73
|
$stdout.flush
|
63
74
|
rescue IOError => e
|
64
|
-
# Handle broken pipe gracefully
|
65
75
|
@configuration.logger.debug("Failed to send notification", error: e.message) if @configuration.logging_enabled?
|
66
76
|
end
|
67
77
|
|
68
78
|
private
|
69
79
|
|
80
|
+
# Handle a cancellation notification from the client
|
81
|
+
#
|
82
|
+
# @param message [Hash] the cancellation notification message
|
83
|
+
def handle_cancellation(message)
|
84
|
+
params = message["params"]
|
85
|
+
return unless params
|
86
|
+
|
87
|
+
request_id = params["requestId"]
|
88
|
+
return unless request_id
|
89
|
+
|
90
|
+
@request_store.mark_cancelled(request_id)
|
91
|
+
rescue
|
92
|
+
nil
|
93
|
+
end
|
94
|
+
|
70
95
|
def receive_message
|
71
96
|
$stdin.gets
|
72
97
|
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module ModelContextProtocol
|
2
|
+
class Server::StreamableHttpTransport
|
3
|
+
class EventCounter
|
4
|
+
COUNTER_KEY_PREFIX = "event_counter:"
|
5
|
+
|
6
|
+
def initialize(redis_client, server_instance)
|
7
|
+
@redis = redis_client
|
8
|
+
@server_instance = server_instance
|
9
|
+
@counter_key = "#{COUNTER_KEY_PREFIX}#{server_instance}"
|
10
|
+
|
11
|
+
if @redis.exists(@counter_key) == 0
|
12
|
+
@redis.set(@counter_key, 0)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def next_event_id
|
17
|
+
count = @redis.incr(@counter_key)
|
18
|
+
"#{@server_instance}-#{count}"
|
19
|
+
end
|
20
|
+
|
21
|
+
def current_count
|
22
|
+
count = @redis.get(@counter_key)
|
23
|
+
count ? count.to_i : 0
|
24
|
+
end
|
25
|
+
|
26
|
+
def reset
|
27
|
+
@redis.set(@counter_key, 0)
|
28
|
+
end
|
29
|
+
|
30
|
+
def set_count(value)
|
31
|
+
@redis.set(@counter_key, value.to_i)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|