model-context-protocol-rb 0.3.2 → 0.3.3
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 +13 -2
- data/README.md +172 -34
- data/lib/model_context_protocol/server/completion.rb +6 -0
- data/lib/model_context_protocol/server/configuration.rb +94 -2
- data/lib/model_context_protocol/server/mcp_logger.rb +109 -0
- data/lib/model_context_protocol/server/prompt.rb +8 -6
- data/lib/model_context_protocol/server/resource.rb +6 -4
- data/lib/model_context_protocol/server/session_store.rb +108 -0
- data/lib/model_context_protocol/server/stdio_transport.rb +26 -11
- data/lib/model_context_protocol/server/streamable_http_transport.rb +291 -0
- data/lib/model_context_protocol/server/tool.rb +6 -4
- data/lib/model_context_protocol/server.rb +45 -6
- data/lib/model_context_protocol/version.rb +1 -1
- data/tasks/templates/dev.erb +10 -1
- metadata +5 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5cce144594a63393d124f7edc41428730824f2e5df282b21458b506d1ac59376
|
4
|
+
data.tar.gz: f7a1de949d083fbcf9df57f500cad32869aa2c42b7dda17726509bab58ffc0b2
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: f374a5e4cf00d1f905f86c51be35c812b6074c188dca28e2724a9e478afdba641337b4f17404ed44cdb22a86baf88dbcaf03df4896e5b039f1717eb3c8afbb71
|
7
|
+
data.tar.gz: 8bb5dd4e584f9df32265d1a88d9469e73b2c70225a88da510240bb5b7f80ef4f94b13738ed66a1f1bcec39012832efc0032d72d7ff93ede737ec31d737589243
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,15 @@
|
|
1
1
|
## [Unreleased]
|
2
2
|
|
3
|
+
## [0.3.3] - 2025-09-02
|
4
|
+
|
5
|
+
- (Breaking) Added logging support.
|
6
|
+
- Requires updating the `enable_log` configuration option to `logging_enabled`.
|
7
|
+
- Added experimental Streamable HTTP transport.
|
8
|
+
- (Breaking) Renamed params to arguments in prompts, resources, and tools.
|
9
|
+
- Requires updating all references to `params` in prompts, resources, and tools to `arguments` with symbolized keys.
|
10
|
+
- Improved ergonomics of completions and resource templates.
|
11
|
+
- Added support for providing context to prompts, resources, and tools.
|
12
|
+
|
3
13
|
## [0.3.2] - 2025-05-10
|
4
14
|
|
5
15
|
- Added resource template support.
|
@@ -37,8 +47,9 @@
|
|
37
47
|
|
38
48
|
- Initial release
|
39
49
|
|
40
|
-
[Unreleased]: https://github.com/dickdavis/model-context-protocol-rb/compare/v0.3.
|
41
|
-
[0.3.
|
50
|
+
[Unreleased]: https://github.com/dickdavis/model-context-protocol-rb/compare/v0.3.3...HEAD
|
51
|
+
[0.3.3]: https://github.com/dickdavis/model-context-protocol-rb/compare/v0.3.2...v0.3.3
|
52
|
+
[0.3.2]: https://github.com/dickdavis/model-context-protocol-rb/compare/v0.3.1...v0.3.2
|
42
53
|
[0.3.1]: https://github.com/dickdavis/model-context-protocol-rb/compare/v0.3.0...v0.3.1
|
43
54
|
[0.3.0]: https://github.com/dickdavis/model-context-protocol-rb/compare/v0.2.0...v0.3.0
|
44
55
|
[0.2.0]: https://github.com/dickdavis/model-context-protocol-rb/compare/v0.1.0...v0.2.0
|
data/README.md
CHANGED
@@ -30,7 +30,7 @@ Build a simple MCP server by registering your prompts, resources, resource templ
|
|
30
30
|
server = ModelContextProtocol::Server.new do |config|
|
31
31
|
config.name = "MCP Development Server"
|
32
32
|
config.version = "1.0.0"
|
33
|
-
config.
|
33
|
+
config.logging_enabled = true
|
34
34
|
|
35
35
|
# Environment Variables - https://modelcontextprotocol.io/docs/tools/debugging#environment-variables
|
36
36
|
# Require specific environment variables to be set
|
@@ -39,6 +39,12 @@ server = ModelContextProtocol::Server.new do |config|
|
|
39
39
|
# Set environment variables programmatically
|
40
40
|
config.set_environment_variable("DEBUG_MODE", "true")
|
41
41
|
|
42
|
+
# Provide prompts, resources, and tools with contextual variables
|
43
|
+
config.context = {
|
44
|
+
user_id: "123456",
|
45
|
+
request_id: SecureRandom.uuid
|
46
|
+
}
|
47
|
+
|
42
48
|
config.registry = ModelContextProtocol::Server::Registry.new do
|
43
49
|
prompts list_changed: true do
|
44
50
|
register TestPrompt
|
@@ -61,45 +67,159 @@ end
|
|
61
67
|
server.start
|
62
68
|
```
|
63
69
|
|
70
|
+
### Transport Configuration
|
71
|
+
|
72
|
+
The MCP server supports different transport mechanisms for communication with clients. By default, it uses stdio (standard input/output), but you can also configure it to use streamable HTTP transport for distributed deployments.
|
73
|
+
|
74
|
+
#### Stdio Transport (Default)
|
75
|
+
|
76
|
+
When no transport is specified, the server uses stdio transport, which is suitable for single-process communication:
|
77
|
+
|
78
|
+
```ruby
|
79
|
+
server = ModelContextProtocol::Server.new do |config|
|
80
|
+
config.name = "MCP Development Server"
|
81
|
+
config.version = "1.0.0"
|
82
|
+
# No transport specified - uses stdio by default
|
83
|
+
config.registry = ModelContextProtocol::Server::Registry.new
|
84
|
+
end
|
85
|
+
|
86
|
+
server.start
|
87
|
+
```
|
88
|
+
|
89
|
+
#### Streamable HTTP Transport
|
90
|
+
|
91
|
+
For distributed deployments with load balancers and multiple server instances, use the streamable HTTP transport with Redis-backed session management:
|
92
|
+
|
93
|
+
```ruby
|
94
|
+
require 'redis'
|
95
|
+
|
96
|
+
server = ModelContextProtocol::Server.new do |config|
|
97
|
+
config.name = "MCP Development Server"
|
98
|
+
config.version = "1.0.0"
|
99
|
+
|
100
|
+
# Configure streamable HTTP transport
|
101
|
+
config.transport = {
|
102
|
+
type: :streamable_http,
|
103
|
+
redis_client: Redis.new(url: ENV['REDIS_URL']),
|
104
|
+
session_ttl: 3600 # Optional: session timeout in seconds (default: 3600)
|
105
|
+
}
|
106
|
+
|
107
|
+
config.registry = ModelContextProtocol::Server::Registry.new
|
108
|
+
end
|
109
|
+
|
110
|
+
# For HTTP frameworks, handle the request and return the response
|
111
|
+
result = server.start
|
112
|
+
# result will be a hash like: {json: {...}, status: 200, headers: {...}}
|
113
|
+
```
|
114
|
+
|
115
|
+
**Key Features:**
|
116
|
+
- **Distributed Sessions**: Redis-backed session storage enables multiple server instances
|
117
|
+
- **Load Balancer Support**: Sessions persist across different server instances
|
118
|
+
- **HTTP Methods**: Supports POST (requests), GET (Server-Sent Events), DELETE (cleanup)
|
119
|
+
- **Cross-Server Routing**: Messages are routed between servers via Redis pub/sub
|
120
|
+
|
121
|
+
**Integration Example (Rails):**
|
122
|
+
|
123
|
+
```ruby
|
124
|
+
class McpController < ApplicationController
|
125
|
+
def handle
|
126
|
+
server = ModelContextProtocol::Server.new do |config|
|
127
|
+
config.name = "Rails MCP Server"
|
128
|
+
config.version = "1.0.0"
|
129
|
+
config.transport = {
|
130
|
+
type: :streamable_http,
|
131
|
+
redis_client: Redis.new(url: ENV['REDIS_URL']),
|
132
|
+
request: request,
|
133
|
+
response: response
|
134
|
+
}
|
135
|
+
config.registry = build_registry
|
136
|
+
end
|
137
|
+
|
138
|
+
result = server.start
|
139
|
+
render json: result[:json], status: result[:status], headers: result[:headers]
|
140
|
+
end
|
141
|
+
end
|
142
|
+
```
|
143
|
+
|
64
144
|
Messages from the MCP client will be routed to the appropriate custom handler. This SDK provides several classes that should be used to build your handlers.
|
65
145
|
|
146
|
+
### Server features
|
147
|
+
|
66
148
|
#### Prompts
|
67
149
|
|
68
150
|
The `ModelContextProtocol::Server::Prompt` base class allows subclasses to define a prompt that the MCP client can use. Define the [appropriate metadata](https://spec.modelcontextprotocol.io/specification/2024-11-05/server/prompts/) in the `with_metadata` block.
|
69
151
|
|
70
|
-
Define any arguments using the `with_argument` block. You can mark an argument as required, and you can optionally provide
|
152
|
+
Define any arguments using the `with_argument` block. You can mark an argument as required, and you can optionally provide a completion class. See [Completions](#completions) for more information.
|
153
|
+
|
154
|
+
Then implement the `call` method to build your prompt. Any arguments passed to the tool from the MCP client will be available in the `arguments` hash with symbol keys (e.g., `arguments[:argument_name]`), and any context values provided in the server configuration will be available in the `context` hash. Use the `respond_with` instance method to ensure your prompt responds with appropriately formatted response data.
|
71
155
|
|
72
|
-
|
156
|
+
You can also log from within your prompt by calling a valid logger level method on the `logger` and passing a string message.
|
73
157
|
|
74
158
|
This is an example prompt that returns a properly formatted response:
|
75
159
|
|
76
160
|
```ruby
|
77
161
|
class TestPrompt < ModelContextProtocol::Server::Prompt
|
162
|
+
ToneCompletion = ModelContextProtocol::Server::Completion.define do
|
163
|
+
hints = ["whiny", "angry", "callous", "desperate", "nervous", "sneaky"]
|
164
|
+
values = hints.grep(/#{argument_value}/)
|
165
|
+
|
166
|
+
respond_with values:
|
167
|
+
end
|
168
|
+
|
78
169
|
with_metadata do
|
79
|
-
name "
|
80
|
-
description "A
|
170
|
+
name "brainstorm_excuses"
|
171
|
+
description "A prompt for brainstorming excuses to get out of something"
|
81
172
|
end
|
82
173
|
|
83
174
|
with_argument do
|
84
|
-
name "
|
85
|
-
description "The thing to
|
175
|
+
name "undesirable_activity"
|
176
|
+
description "The thing to get out of"
|
86
177
|
required true
|
87
|
-
completion TestCompletion
|
88
178
|
end
|
89
179
|
|
90
180
|
with_argument do
|
91
|
-
name "
|
92
|
-
description "
|
181
|
+
name "tone"
|
182
|
+
description "The general tone to be used in the generated excuses"
|
93
183
|
required false
|
184
|
+
completion ToneCompletion
|
94
185
|
end
|
95
186
|
|
96
187
|
def call
|
188
|
+
logger.info("Brainstorming excuses...")
|
97
189
|
messages = [
|
98
190
|
{
|
99
191
|
role: "user",
|
100
192
|
content: {
|
101
193
|
type: "text",
|
102
|
-
text: "
|
194
|
+
text: "My wife wants me to: #{arguments[:undesirable_activity]}... Can you believe it?"
|
195
|
+
}
|
196
|
+
},
|
197
|
+
{
|
198
|
+
role: "assistant",
|
199
|
+
content: {
|
200
|
+
type: "text",
|
201
|
+
text: "Oh, that's just downright awful. What are you going to do?"
|
202
|
+
}
|
203
|
+
},
|
204
|
+
{
|
205
|
+
role: "user",
|
206
|
+
content: {
|
207
|
+
type: "text",
|
208
|
+
text: "Well, I'd like to get out of it, but I'm going to need your help."
|
209
|
+
}
|
210
|
+
},
|
211
|
+
{
|
212
|
+
role: "assistant",
|
213
|
+
content: {
|
214
|
+
type: "text",
|
215
|
+
text: "Anything for you."
|
216
|
+
}
|
217
|
+
},
|
218
|
+
{
|
219
|
+
role: "user",
|
220
|
+
content: {
|
221
|
+
type: "text",
|
222
|
+
text: "Can you generate some excuses for me?" + (arguments[:tone] ? "Make them as #{arguments[:tone]} as possible." : "")
|
103
223
|
}
|
104
224
|
}
|
105
225
|
]
|
@@ -113,21 +233,35 @@ end
|
|
113
233
|
|
114
234
|
The `ModelContextProtocol::Server::Resource` base class allows subclasses to define a resource that the MCP client can use. Define the [appropriate metadata](https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/) in the `with_metadata` block.
|
115
235
|
|
116
|
-
Then, implement the `call` method to build your resource. Use the `respond_with` instance method to ensure your resource responds with appropriately formatted response data.
|
236
|
+
Then, implement the `call` method to build your resource. Any context values provided in the server configuration will be available in the `context` hash. Use the `respond_with` instance method to ensure your resource responds with appropriately formatted response data.
|
237
|
+
|
238
|
+
You can also log from within your resource by calling a valid logger level method on the `logger` and passing a string message.
|
117
239
|
|
118
240
|
This is an example resource that returns a text response:
|
119
241
|
|
120
242
|
```ruby
|
121
243
|
class TestResource < ModelContextProtocol::Server::Resource
|
122
244
|
with_metadata do
|
123
|
-
name "
|
124
|
-
description "
|
245
|
+
name "top-secret-plans.txt"
|
246
|
+
description "Top secret plans to do top secret things"
|
125
247
|
mime_type "text/plain"
|
126
|
-
uri "
|
248
|
+
uri "file:///top-secret-plans.txt"
|
127
249
|
end
|
128
250
|
|
129
251
|
def call
|
130
|
-
|
252
|
+
unless authorized?(context[:user_id])
|
253
|
+
logger.info("This fool thinks he can get my top secret plans...")
|
254
|
+
return respond_with :text, text: "Nothing to see here, move along."
|
255
|
+
end
|
256
|
+
|
257
|
+
respond_with :text, text: "I'm finna eat all my wife's leftovers."
|
258
|
+
end
|
259
|
+
|
260
|
+
private
|
261
|
+
|
262
|
+
def authorized?(user_id)
|
263
|
+
authorized_users = ["42", "123456"]
|
264
|
+
authorized_users.any?(user_id)
|
131
265
|
end
|
132
266
|
end
|
133
267
|
```
|
@@ -137,14 +271,15 @@ This is an example resource that returns binary data:
|
|
137
271
|
```ruby
|
138
272
|
class TestBinaryResource < ModelContextProtocol::Server::Resource
|
139
273
|
with_metadata do
|
140
|
-
name "
|
274
|
+
name "project-logo.png"
|
141
275
|
description "The logo for the project"
|
142
|
-
mime_type "image/
|
143
|
-
uri "
|
276
|
+
mime_type "image/png"
|
277
|
+
uri "file:///project-logo.png"
|
144
278
|
end
|
145
279
|
|
146
280
|
def call
|
147
281
|
# In a real implementation, we would retrieve the binary resource
|
282
|
+
# This is a small valid base64 encoded string (represents "test")
|
148
283
|
data = "dGVzdA=="
|
149
284
|
respond_with :binary, blob: data
|
150
285
|
end
|
@@ -158,24 +293,22 @@ The `ModelContextProtocol::Server::ResourceTemplate` base class allows subclasse
|
|
158
293
|
This is an example resource template that provides a completion for a parameter of the URI template:
|
159
294
|
|
160
295
|
```ruby
|
161
|
-
class
|
162
|
-
|
296
|
+
class TestResourceTemplate < ModelContextProtocol::Server::ResourceTemplate
|
297
|
+
Completion = ModelContextProtocol::Server::Completion.define do
|
163
298
|
hints = {
|
164
|
-
"name" => ["
|
299
|
+
"name" => ["top-secret-plans.txt"]
|
165
300
|
}
|
166
301
|
values = hints[argument_name].grep(/#{argument_value}/)
|
167
302
|
|
168
303
|
respond_with values:
|
169
304
|
end
|
170
|
-
end
|
171
305
|
|
172
|
-
class TestResourceTemplate < ModelContextProtocol::Server::ResourceTemplate
|
173
306
|
with_metadata do
|
174
|
-
name "
|
175
|
-
description "A
|
307
|
+
name "project-document-resource-template"
|
308
|
+
description "A resource template for retrieving project documents"
|
176
309
|
mime_type "text/plain"
|
177
|
-
uri_template "
|
178
|
-
completion :name,
|
310
|
+
uri_template "file:///{name}" do
|
311
|
+
completion :name, Completion
|
179
312
|
end
|
180
313
|
end
|
181
314
|
end
|
@@ -185,7 +318,9 @@ end
|
|
185
318
|
|
186
319
|
The `ModelContextProtocol::Server::Tool` base class allows subclasses to define a tool that the MCP client can use. Define the [appropriate metadata](https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/) in the `with_metadata` block.
|
187
320
|
|
188
|
-
Then implement the `call` method to build your tool. Use the `respond_with` instance method to ensure your tool responds with appropriately formatted response data.
|
321
|
+
Then, implement the `call` method to build your tool. Any arguments passed to the tool from the MCP client will be available in the `arguments` hash with symbol keys (e.g., `arguments[:argument_name]`), and any context values provided in the server configuration will be available in the `context` hash. Use the `respond_with` instance method to ensure your tool responds with appropriately formatted response data.
|
322
|
+
|
323
|
+
You can also log from within your tool by calling a valid logger level method on the `logger` and passing a string message.
|
189
324
|
|
190
325
|
This is an example tool that returns a text response:
|
191
326
|
|
@@ -208,9 +343,12 @@ class TestToolWithTextResponse < ModelContextProtocol::Server::Tool
|
|
208
343
|
end
|
209
344
|
|
210
345
|
def call
|
211
|
-
|
212
|
-
|
213
|
-
|
346
|
+
user_id = context[:user_id]
|
347
|
+
number = arguments[:number].to_i
|
348
|
+
logger.info("Silly user doesn't know how to double a number")
|
349
|
+
calculation = number * 2
|
350
|
+
salutation = user_id ? "User #{user_id}, " : ""
|
351
|
+
respond_with :text, text: salutation << "#{number} doubled is #{calculation}"
|
214
352
|
end
|
215
353
|
end
|
216
354
|
```
|
@@ -242,7 +380,7 @@ class TestToolWithImageResponse < ModelContextProtocol::Server::Tool
|
|
242
380
|
|
243
381
|
def call
|
244
382
|
# Map format to mime type
|
245
|
-
mime_type = case
|
383
|
+
mime_type = case arguments[:format].downcase
|
246
384
|
when "svg"
|
247
385
|
"image/svg+xml"
|
248
386
|
when "jpg", "jpeg"
|
@@ -311,7 +449,7 @@ class TestToolWithResourceResponse < ModelContextProtocol::Server::Tool
|
|
311
449
|
end
|
312
450
|
|
313
451
|
def call
|
314
|
-
title =
|
452
|
+
title = arguments[:title].downcase
|
315
453
|
# In a real implementation, we would do a lookup to get the document data
|
316
454
|
document = "richtextdata"
|
317
455
|
respond_with :resource, uri: "resource://document/#{title}", text: document, mime_type: "application/rtf"
|
@@ -1,3 +1,5 @@
|
|
1
|
+
require_relative "mcp_logger"
|
2
|
+
|
1
3
|
module ModelContextProtocol
|
2
4
|
class Server::Configuration
|
3
5
|
# Raised when configured with invalid name.
|
@@ -12,16 +14,74 @@ module ModelContextProtocol
|
|
12
14
|
# Raised when a required environment variable is not set
|
13
15
|
class MissingRequiredEnvironmentVariable < StandardError; end
|
14
16
|
|
15
|
-
|
17
|
+
# Raised when transport configuration is invalid
|
18
|
+
class InvalidTransportError < StandardError; end
|
19
|
+
|
20
|
+
# Raised when an invalid log level is provided
|
21
|
+
class InvalidLogLevelError < StandardError; end
|
22
|
+
|
23
|
+
# Valid MCP log levels per the specification
|
24
|
+
VALID_LOG_LEVELS = %w[debug info notice warning error critical alert emergency].freeze
|
25
|
+
|
26
|
+
attr_accessor :name, :registry, :version, :transport
|
27
|
+
attr_reader :logger
|
28
|
+
|
29
|
+
def initialize
|
30
|
+
# Always create a logger - enabled by default
|
31
|
+
@logging_enabled = true
|
32
|
+
@default_log_level = "info"
|
33
|
+
@logger = ModelContextProtocol::Server::MCPLogger.new(
|
34
|
+
logger_name: "server",
|
35
|
+
level: @default_log_level,
|
36
|
+
enabled: @logging_enabled
|
37
|
+
)
|
38
|
+
end
|
16
39
|
|
17
40
|
def logging_enabled?
|
18
|
-
|
41
|
+
@logging_enabled
|
42
|
+
end
|
43
|
+
|
44
|
+
def logging_enabled=(value)
|
45
|
+
@logging_enabled = value
|
46
|
+
@logger = ModelContextProtocol::Server::MCPLogger.new(
|
47
|
+
logger_name: "server",
|
48
|
+
level: @default_log_level,
|
49
|
+
enabled: value
|
50
|
+
)
|
51
|
+
end
|
52
|
+
|
53
|
+
def default_log_level=(level)
|
54
|
+
unless VALID_LOG_LEVELS.include?(level.to_s)
|
55
|
+
raise InvalidLogLevelError, "Invalid log level: #{level}. Valid levels are: #{VALID_LOG_LEVELS.join(", ")}"
|
56
|
+
end
|
57
|
+
|
58
|
+
@default_log_level = level.to_s
|
59
|
+
@logger.set_mcp_level(@default_log_level)
|
60
|
+
end
|
61
|
+
|
62
|
+
def transport_type
|
63
|
+
case transport
|
64
|
+
when Hash
|
65
|
+
transport[:type] || transport["type"]
|
66
|
+
when Symbol, String
|
67
|
+
transport.to_sym
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def transport_options
|
72
|
+
case transport
|
73
|
+
when Hash
|
74
|
+
transport.except(:type, "type").transform_keys(&:to_sym)
|
75
|
+
else
|
76
|
+
{}
|
77
|
+
end
|
19
78
|
end
|
20
79
|
|
21
80
|
def validate!
|
22
81
|
raise InvalidServerNameError unless valid_name?
|
23
82
|
raise InvalidRegistryError unless valid_registry?
|
24
83
|
raise InvalidServerVersionError unless valid_version?
|
84
|
+
validate_transport!
|
25
85
|
|
26
86
|
validate_environment_variables!
|
27
87
|
end
|
@@ -51,6 +111,14 @@ module ModelContextProtocol
|
|
51
111
|
environment_variables[key.to_s.upcase] = value
|
52
112
|
end
|
53
113
|
|
114
|
+
def context
|
115
|
+
@context ||= {}
|
116
|
+
end
|
117
|
+
|
118
|
+
def context=(context_hash = {})
|
119
|
+
@context = context_hash
|
120
|
+
end
|
121
|
+
|
54
122
|
private
|
55
123
|
|
56
124
|
def required_environment_variables
|
@@ -74,5 +142,29 @@ module ModelContextProtocol
|
|
74
142
|
def valid_version?
|
75
143
|
version&.is_a?(String)
|
76
144
|
end
|
145
|
+
|
146
|
+
def validate_transport!
|
147
|
+
case transport_type
|
148
|
+
when :streamable_http
|
149
|
+
validate_streamable_http_transport!
|
150
|
+
when :stdio, nil
|
151
|
+
# stdio transport has no required options
|
152
|
+
else
|
153
|
+
raise InvalidTransportError, "Unknown transport type: #{transport_type}" if transport_type
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
def validate_streamable_http_transport!
|
158
|
+
options = transport_options
|
159
|
+
|
160
|
+
unless options[:redis_client]
|
161
|
+
raise InvalidTransportError, "streamable_http transport requires redis_client option"
|
162
|
+
end
|
163
|
+
|
164
|
+
redis_client = options[:redis_client]
|
165
|
+
unless redis_client.respond_to?(:hset) && redis_client.respond_to?(:expire)
|
166
|
+
raise InvalidTransportError, "redis_client must be a Redis-compatible client"
|
167
|
+
end
|
168
|
+
end
|
77
169
|
end
|
78
170
|
end
|
@@ -0,0 +1,109 @@
|
|
1
|
+
require "logger"
|
2
|
+
require "forwardable"
|
3
|
+
require "json"
|
4
|
+
|
5
|
+
module ModelContextProtocol
|
6
|
+
class Server::MCPLogger
|
7
|
+
extend Forwardable
|
8
|
+
|
9
|
+
def_delegators :@internal_logger, :datetime_format=, :formatter=, :progname, :progname=
|
10
|
+
|
11
|
+
LEVEL_MAP = {
|
12
|
+
"debug" => Logger::DEBUG,
|
13
|
+
"info" => Logger::INFO,
|
14
|
+
"notice" => Logger::INFO,
|
15
|
+
"warning" => Logger::WARN,
|
16
|
+
"error" => Logger::ERROR,
|
17
|
+
"critical" => Logger::FATAL,
|
18
|
+
"alert" => Logger::FATAL,
|
19
|
+
"emergency" => Logger::UNKNOWN
|
20
|
+
}.freeze
|
21
|
+
|
22
|
+
REVERSE_LEVEL_MAP = {
|
23
|
+
Logger::DEBUG => "debug",
|
24
|
+
Logger::INFO => "info",
|
25
|
+
Logger::WARN => "warning",
|
26
|
+
Logger::ERROR => "error",
|
27
|
+
Logger::FATAL => "critical",
|
28
|
+
Logger::UNKNOWN => "emergency"
|
29
|
+
}.freeze
|
30
|
+
|
31
|
+
attr_accessor :transport
|
32
|
+
attr_reader :logger_name, :enabled
|
33
|
+
|
34
|
+
def initialize(logger_name: "server", level: "info", enabled: true)
|
35
|
+
@logger_name = logger_name
|
36
|
+
@enabled = enabled
|
37
|
+
@internal_logger = Logger.new(nil)
|
38
|
+
@internal_logger.level = LEVEL_MAP[level] || Logger::INFO
|
39
|
+
@transport = nil
|
40
|
+
@queued_messages = []
|
41
|
+
end
|
42
|
+
|
43
|
+
%i[debug info warn error fatal unknown].each do |severity|
|
44
|
+
define_method(severity) do |message = nil, **data, &block|
|
45
|
+
return true unless @enabled
|
46
|
+
add(Logger.const_get(severity.to_s.upcase), message, data, &block)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def add(severity, message = nil, data = {}, &block)
|
51
|
+
return true unless @enabled
|
52
|
+
return true if severity < @internal_logger.level
|
53
|
+
|
54
|
+
message = block.call if message.nil? && block_given?
|
55
|
+
send_notification(severity, message, data)
|
56
|
+
true
|
57
|
+
end
|
58
|
+
|
59
|
+
def level=(value)
|
60
|
+
@internal_logger.level = value
|
61
|
+
end
|
62
|
+
|
63
|
+
def level
|
64
|
+
@internal_logger.level
|
65
|
+
end
|
66
|
+
|
67
|
+
def set_mcp_level(mcp_level)
|
68
|
+
self.level = LEVEL_MAP[mcp_level] || Logger::INFO
|
69
|
+
end
|
70
|
+
|
71
|
+
def connect_transport(transport)
|
72
|
+
@transport = transport
|
73
|
+
flush_queued_messages if @enabled
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
def send_notification(severity, message, data)
|
79
|
+
return unless @enabled
|
80
|
+
|
81
|
+
notification_params = {
|
82
|
+
level: REVERSE_LEVEL_MAP[severity] || "info",
|
83
|
+
logger: @logger_name,
|
84
|
+
data: format_data(message, data)
|
85
|
+
}
|
86
|
+
|
87
|
+
if @transport
|
88
|
+
@transport.send_notification("notifications/message", notification_params)
|
89
|
+
else
|
90
|
+
@queued_messages << notification_params
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def format_data(message, additional_data)
|
95
|
+
data = {}
|
96
|
+
data[:message] = message.to_s if message
|
97
|
+
data.merge!(additional_data) unless additional_data.empty?
|
98
|
+
data
|
99
|
+
end
|
100
|
+
|
101
|
+
def flush_queued_messages
|
102
|
+
return unless @transport && @enabled
|
103
|
+
@queued_messages.each do |params|
|
104
|
+
@transport.send_notification("notifications/message", params)
|
105
|
+
end
|
106
|
+
@queued_messages.clear
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
@@ -1,10 +1,12 @@
|
|
1
1
|
module ModelContextProtocol
|
2
2
|
class Server::Prompt
|
3
|
-
attr_reader :params
|
3
|
+
attr_reader :params, :context, :logger
|
4
4
|
|
5
|
-
def initialize(params)
|
5
|
+
def initialize(params, logger, context = {})
|
6
6
|
validate!(params)
|
7
7
|
@params = params
|
8
|
+
@context = context
|
9
|
+
@logger = logger
|
8
10
|
end
|
9
11
|
|
10
12
|
def call
|
@@ -24,8 +26,8 @@ module ModelContextProtocol
|
|
24
26
|
|
25
27
|
private def validate!(params = {})
|
26
28
|
arguments = self.class.arguments || []
|
27
|
-
required_args = arguments.select { |arg| arg[:required] }.map { |arg| arg[:name] }
|
28
|
-
valid_arg_names = arguments.map { |arg| arg[:name] }
|
29
|
+
required_args = arguments.select { |arg| arg[:required] }.map { |arg| arg[:name].to_sym }
|
30
|
+
valid_arg_names = arguments.map { |arg| arg[:name].to_sym }
|
29
31
|
|
30
32
|
missing_args = required_args - params.keys
|
31
33
|
unless missing_args.empty?
|
@@ -73,8 +75,8 @@ module ModelContextProtocol
|
|
73
75
|
subclass.instance_variable_set(:@arguments, @arguments&.dup)
|
74
76
|
end
|
75
77
|
|
76
|
-
def call(params)
|
77
|
-
new(params).call
|
78
|
+
def call(params, logger, context = {})
|
79
|
+
new(params, logger, context).call
|
78
80
|
rescue ArgumentError => error
|
79
81
|
raise ModelContextProtocol::Server::ParameterValidationError, error.message
|
80
82
|
end
|
@@ -1,10 +1,12 @@
|
|
1
1
|
module ModelContextProtocol
|
2
2
|
class Server::Resource
|
3
|
-
attr_reader :mime_type, :uri
|
3
|
+
attr_reader :mime_type, :uri, :context, :logger
|
4
4
|
|
5
|
-
def initialize
|
5
|
+
def initialize(logger, context = {})
|
6
6
|
@mime_type = self.class.mime_type
|
7
7
|
@uri = self.class.uri
|
8
|
+
@context = context
|
9
|
+
@logger = logger
|
8
10
|
end
|
9
11
|
|
10
12
|
def call
|
@@ -56,8 +58,8 @@ module ModelContextProtocol
|
|
56
58
|
subclass.instance_variable_set(:@uri, @uri)
|
57
59
|
end
|
58
60
|
|
59
|
-
def call
|
60
|
-
new.call
|
61
|
+
def call(logger, context = {})
|
62
|
+
new(logger, context).call
|
61
63
|
end
|
62
64
|
|
63
65
|
def metadata
|
@@ -0,0 +1,108 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "securerandom"
|
5
|
+
|
6
|
+
module ModelContextProtocol
|
7
|
+
class Server
|
8
|
+
class SessionStore
|
9
|
+
def initialize(redis_client, ttl: 3600)
|
10
|
+
@redis = redis_client
|
11
|
+
@ttl = ttl
|
12
|
+
end
|
13
|
+
|
14
|
+
def create_session(session_id, data)
|
15
|
+
session_data = {
|
16
|
+
id: session_id,
|
17
|
+
server_instance: data[:server_instance],
|
18
|
+
context: data[:context] || {},
|
19
|
+
created_at: data[:created_at] || Time.now.to_f,
|
20
|
+
last_activity: Time.now.to_f,
|
21
|
+
active_stream: false
|
22
|
+
}
|
23
|
+
|
24
|
+
@redis.hset("session:#{session_id}", session_data.transform_values(&:to_json))
|
25
|
+
@redis.expire("session:#{session_id}", @ttl)
|
26
|
+
session_id
|
27
|
+
end
|
28
|
+
|
29
|
+
def mark_stream_active(session_id, server_instance)
|
30
|
+
@redis.multi do |multi|
|
31
|
+
multi.hset("session:#{session_id}",
|
32
|
+
"active_stream", true.to_json,
|
33
|
+
"stream_server", server_instance.to_json,
|
34
|
+
"last_activity", Time.now.to_f.to_json)
|
35
|
+
multi.expire("session:#{session_id}", @ttl)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def mark_stream_inactive(session_id)
|
40
|
+
@redis.multi do |multi|
|
41
|
+
multi.hset("session:#{session_id}",
|
42
|
+
"active_stream", false.to_json,
|
43
|
+
"stream_server", nil.to_json,
|
44
|
+
"last_activity", Time.now.to_f.to_json)
|
45
|
+
multi.expire("session:#{session_id}", @ttl)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def get_session_server(session_id)
|
50
|
+
server_data = @redis.hget("session:#{session_id}", "stream_server")
|
51
|
+
server_data ? JSON.parse(server_data) : nil
|
52
|
+
end
|
53
|
+
|
54
|
+
def session_exists?(session_id)
|
55
|
+
@redis.exists("session:#{session_id}") == 1
|
56
|
+
end
|
57
|
+
|
58
|
+
def session_has_active_stream?(session_id)
|
59
|
+
stream_data = @redis.hget("session:#{session_id}", "active_stream")
|
60
|
+
stream_data ? JSON.parse(stream_data) : false
|
61
|
+
end
|
62
|
+
|
63
|
+
def get_session_context(session_id)
|
64
|
+
context_data = @redis.hget("session:#{session_id}", "context")
|
65
|
+
context_data ? JSON.parse(context_data) : {}
|
66
|
+
end
|
67
|
+
|
68
|
+
def cleanup_session(session_id)
|
69
|
+
@redis.del("session:#{session_id}")
|
70
|
+
end
|
71
|
+
|
72
|
+
def route_message_to_session(session_id, message)
|
73
|
+
server_instance = get_session_server(session_id)
|
74
|
+
return false unless server_instance
|
75
|
+
|
76
|
+
# Publish to server-specific channel
|
77
|
+
@redis.publish("server:#{server_instance}:messages", {
|
78
|
+
session_id: session_id,
|
79
|
+
message: message
|
80
|
+
}.to_json)
|
81
|
+
true
|
82
|
+
end
|
83
|
+
|
84
|
+
def subscribe_to_server(server_instance, &block)
|
85
|
+
@redis.subscribe("server:#{server_instance}:messages") do |on|
|
86
|
+
on.message do |channel, message|
|
87
|
+
data = JSON.parse(message)
|
88
|
+
yield(data)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def get_all_active_sessions
|
94
|
+
keys = @redis.keys("session:*")
|
95
|
+
active_sessions = []
|
96
|
+
|
97
|
+
keys.each do |key|
|
98
|
+
session_id = key.sub("session:", "")
|
99
|
+
if session_has_active_stream?(session_id)
|
100
|
+
active_sessions << session_id
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
active_sessions
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
@@ -12,16 +12,19 @@ module ModelContextProtocol
|
|
12
12
|
end
|
13
13
|
end
|
14
14
|
|
15
|
-
attr_reader :
|
15
|
+
attr_reader :router, :configuration
|
16
16
|
|
17
|
-
def initialize(
|
18
|
-
@logger = logger
|
17
|
+
def initialize(router:, configuration:)
|
19
18
|
@router = router
|
19
|
+
@configuration = configuration
|
20
20
|
end
|
21
21
|
|
22
|
-
def
|
22
|
+
def handle
|
23
|
+
# Connect logger to transport
|
24
|
+
@configuration.logger.connect_transport(self)
|
25
|
+
|
23
26
|
loop do
|
24
|
-
line =
|
27
|
+
line = receive_message
|
25
28
|
break unless line
|
26
29
|
|
27
30
|
begin
|
@@ -31,18 +34,17 @@ module ModelContextProtocol
|
|
31
34
|
result = router.route(message)
|
32
35
|
send_message(Response[id: message["id"], result: result.serialized])
|
33
36
|
rescue ModelContextProtocol::Server::ParameterValidationError => validation_error
|
34
|
-
|
37
|
+
@configuration.logger.error("Validation error", error: validation_error.message)
|
35
38
|
send_message(
|
36
39
|
ErrorResponse[id: message["id"], error: {code: -32602, message: validation_error.message}]
|
37
40
|
)
|
38
41
|
rescue JSON::ParserError => parser_error
|
39
|
-
|
42
|
+
@configuration.logger.error("Parser error", error: parser_error.message)
|
40
43
|
send_message(
|
41
44
|
ErrorResponse[id: "", error: {code: -32700, message: parser_error.message}]
|
42
45
|
)
|
43
46
|
rescue => error
|
44
|
-
|
45
|
-
log(error.backtrace)
|
47
|
+
@configuration.logger.error("Internal error", error: error.message, backtrace: error.backtrace.first(5))
|
46
48
|
send_message(
|
47
49
|
ErrorResponse[id: message["id"], error: {code: -32603, message: error.message}]
|
48
50
|
)
|
@@ -50,10 +52,23 @@ module ModelContextProtocol
|
|
50
52
|
end
|
51
53
|
end
|
52
54
|
|
55
|
+
def send_notification(method, params)
|
56
|
+
notification = {
|
57
|
+
jsonrpc: "2.0",
|
58
|
+
method: method,
|
59
|
+
params: params
|
60
|
+
}
|
61
|
+
$stdout.puts(JSON.generate(notification))
|
62
|
+
$stdout.flush
|
63
|
+
rescue IOError => e
|
64
|
+
# Handle broken pipe gracefully
|
65
|
+
@configuration.logger.debug("Failed to send notification", error: e.message) if @configuration.logging_enabled?
|
66
|
+
end
|
67
|
+
|
53
68
|
private
|
54
69
|
|
55
|
-
def
|
56
|
-
|
70
|
+
def receive_message
|
71
|
+
$stdin.gets
|
57
72
|
end
|
58
73
|
|
59
74
|
def send_message(message)
|
@@ -0,0 +1,291 @@
|
|
1
|
+
require "json"
|
2
|
+
require "securerandom"
|
3
|
+
|
4
|
+
module ModelContextProtocol
|
5
|
+
class Server::StreamableHttpTransport
|
6
|
+
Response = Data.define(:id, :result) do
|
7
|
+
def serialized
|
8
|
+
{jsonrpc: "2.0", id:, result:}
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
ErrorResponse = Data.define(:id, :error) do
|
13
|
+
def serialized
|
14
|
+
{jsonrpc: "2.0", id:, error:}
|
15
|
+
end
|
16
|
+
end
|
17
|
+
def initialize(router:, configuration:)
|
18
|
+
@router = router
|
19
|
+
@configuration = configuration
|
20
|
+
|
21
|
+
transport_options = @configuration.transport_options
|
22
|
+
@redis = transport_options[:redis_client]
|
23
|
+
|
24
|
+
@session_store = ModelContextProtocol::Server::SessionStore.new(
|
25
|
+
@redis,
|
26
|
+
ttl: transport_options[:session_ttl] || 3600
|
27
|
+
)
|
28
|
+
|
29
|
+
@server_instance = "#{Socket.gethostname}-#{Process.pid}-#{SecureRandom.hex(4)}"
|
30
|
+
@local_streams = {}
|
31
|
+
@notification_queue = []
|
32
|
+
|
33
|
+
setup_redis_subscriber
|
34
|
+
end
|
35
|
+
|
36
|
+
def handle
|
37
|
+
@configuration.logger.connect_transport(self)
|
38
|
+
|
39
|
+
request = @configuration.transport_options[:request]
|
40
|
+
response = @configuration.transport_options[:response]
|
41
|
+
|
42
|
+
unless request && response
|
43
|
+
raise ArgumentError, "StreamableHTTP transport requires request and response objects in transport_options"
|
44
|
+
end
|
45
|
+
|
46
|
+
case request.method
|
47
|
+
when "POST"
|
48
|
+
handle_post_request(request)
|
49
|
+
when "GET"
|
50
|
+
handle_sse_request(request, response)
|
51
|
+
when "DELETE"
|
52
|
+
handle_delete_request(request)
|
53
|
+
else
|
54
|
+
error_response = ErrorResponse[id: nil, error: {code: -32601, message: "Method not allowed"}]
|
55
|
+
{json: error_response.serialized, status: 405}
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def send_notification(method, params)
|
60
|
+
notification = {
|
61
|
+
jsonrpc: "2.0",
|
62
|
+
method: method,
|
63
|
+
params: params
|
64
|
+
}
|
65
|
+
|
66
|
+
if has_active_streams?
|
67
|
+
deliver_to_active_streams(notification)
|
68
|
+
else
|
69
|
+
@notification_queue << notification
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
def handle_post_request(request)
|
76
|
+
body_string = request.body.read
|
77
|
+
body = JSON.parse(body_string)
|
78
|
+
session_id = request.headers["Mcp-Session-Id"]
|
79
|
+
|
80
|
+
case body["method"]
|
81
|
+
when "initialize"
|
82
|
+
handle_initialization(body)
|
83
|
+
else
|
84
|
+
handle_regular_request(body, session_id)
|
85
|
+
end
|
86
|
+
rescue JSON::ParserError
|
87
|
+
error_response = ErrorResponse[id: "", error: {code: -32700, message: "Parse error"}]
|
88
|
+
{json: error_response.serialized, status: 400}
|
89
|
+
rescue ModelContextProtocol::Server::ParameterValidationError => validation_error
|
90
|
+
@configuration.logger.error("Validation error", error: validation_error.message)
|
91
|
+
error_response = ErrorResponse[id: body&.dig("id"), error: {code: -32602, message: validation_error.message}]
|
92
|
+
{json: error_response.serialized, status: 400}
|
93
|
+
rescue => e
|
94
|
+
@configuration.logger.error("Error handling POST request", error: e.message, backtrace: e.backtrace.first(5))
|
95
|
+
error_response = ErrorResponse[id: body&.dig("id"), error: {code: -32603, message: "Internal error"}]
|
96
|
+
{json: error_response.serialized, status: 500}
|
97
|
+
end
|
98
|
+
|
99
|
+
def handle_initialization(body)
|
100
|
+
session_id = SecureRandom.uuid
|
101
|
+
|
102
|
+
@session_store.create_session(session_id, {
|
103
|
+
server_instance: @server_instance,
|
104
|
+
context: @configuration.context || {},
|
105
|
+
created_at: Time.now.to_f
|
106
|
+
})
|
107
|
+
|
108
|
+
result = @router.route(body)
|
109
|
+
response = Response[id: body["id"], result: result.serialized]
|
110
|
+
|
111
|
+
{
|
112
|
+
json: response.serialized,
|
113
|
+
status: 200,
|
114
|
+
headers: {"Mcp-Session-Id" => session_id}
|
115
|
+
}
|
116
|
+
end
|
117
|
+
|
118
|
+
def handle_regular_request(body, session_id)
|
119
|
+
unless session_id && @session_store.session_exists?(session_id)
|
120
|
+
error_response = ErrorResponse[id: body["id"], error: {code: -32600, message: "Invalid or missing session ID"}]
|
121
|
+
return {json: error_response.serialized, status: 400}
|
122
|
+
end
|
123
|
+
|
124
|
+
result = @router.route(body)
|
125
|
+
response = Response[id: body["id"], result: result.serialized]
|
126
|
+
|
127
|
+
if @session_store.session_has_active_stream?(session_id)
|
128
|
+
deliver_to_session_stream(session_id, response.serialized)
|
129
|
+
{json: {accepted: true}, status: 200}
|
130
|
+
else
|
131
|
+
{json: response.serialized, status: 200}
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
def handle_sse_request(request, response)
|
136
|
+
session_id = request.headers["Mcp-Session-Id"]
|
137
|
+
|
138
|
+
unless session_id && @session_store.session_exists?(session_id)
|
139
|
+
error_response = ErrorResponse[id: nil, error: {code: -32600, message: "Invalid or missing session ID"}]
|
140
|
+
return {json: error_response.serialized, status: 400}
|
141
|
+
end
|
142
|
+
|
143
|
+
@session_store.mark_stream_active(session_id, @server_instance)
|
144
|
+
|
145
|
+
{
|
146
|
+
stream: true,
|
147
|
+
headers: {
|
148
|
+
"Content-Type" => "text/event-stream",
|
149
|
+
"Cache-Control" => "no-cache",
|
150
|
+
"Connection" => "keep-alive"
|
151
|
+
},
|
152
|
+
stream_proc: create_sse_stream_proc(session_id)
|
153
|
+
}
|
154
|
+
end
|
155
|
+
|
156
|
+
def handle_delete_request(request)
|
157
|
+
session_id = request.headers["Mcp-Session-Id"]
|
158
|
+
|
159
|
+
if session_id
|
160
|
+
cleanup_session(session_id)
|
161
|
+
end
|
162
|
+
|
163
|
+
{json: {success: true}, status: 200}
|
164
|
+
end
|
165
|
+
|
166
|
+
def create_sse_stream_proc(session_id)
|
167
|
+
proc do |stream|
|
168
|
+
register_local_stream(session_id, stream)
|
169
|
+
|
170
|
+
flush_notifications_to_stream(stream)
|
171
|
+
|
172
|
+
start_keepalive_thread(session_id, stream)
|
173
|
+
|
174
|
+
loop do
|
175
|
+
break unless stream_connected?(stream)
|
176
|
+
sleep 0.1
|
177
|
+
end
|
178
|
+
ensure
|
179
|
+
cleanup_local_stream(session_id)
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
def register_local_stream(session_id, stream)
|
184
|
+
@local_streams[session_id] = stream
|
185
|
+
end
|
186
|
+
|
187
|
+
def cleanup_local_stream(session_id)
|
188
|
+
@local_streams.delete(session_id)
|
189
|
+
@session_store.mark_stream_inactive(session_id)
|
190
|
+
end
|
191
|
+
|
192
|
+
def stream_connected?(stream)
|
193
|
+
return false unless stream
|
194
|
+
|
195
|
+
begin
|
196
|
+
stream.write(": ping\n\n")
|
197
|
+
stream.flush if stream.respond_to?(:flush)
|
198
|
+
true
|
199
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
200
|
+
false
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
def start_keepalive_thread(session_id, stream)
|
205
|
+
Thread.new do
|
206
|
+
loop do
|
207
|
+
sleep 30
|
208
|
+
break unless stream_connected?(stream)
|
209
|
+
|
210
|
+
begin
|
211
|
+
send_ping_to_stream(stream)
|
212
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
213
|
+
break
|
214
|
+
end
|
215
|
+
end
|
216
|
+
rescue => e
|
217
|
+
@configuration.logger.error("Keepalive thread error", error: e.message)
|
218
|
+
ensure
|
219
|
+
cleanup_local_stream(session_id)
|
220
|
+
end
|
221
|
+
end
|
222
|
+
|
223
|
+
def send_ping_to_stream(stream)
|
224
|
+
stream.write(": ping #{Time.now.iso8601}\n\n")
|
225
|
+
stream.flush if stream.respond_to?(:flush)
|
226
|
+
end
|
227
|
+
|
228
|
+
def send_to_stream(stream, data)
|
229
|
+
message = data.is_a?(String) ? data : data.to_json
|
230
|
+
stream.write("data: #{message}\n\n")
|
231
|
+
stream.flush if stream.respond_to?(:flush)
|
232
|
+
end
|
233
|
+
|
234
|
+
def deliver_to_session_stream(session_id, data)
|
235
|
+
if @local_streams[session_id]
|
236
|
+
begin
|
237
|
+
send_to_stream(@local_streams[session_id], data)
|
238
|
+
return true
|
239
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
240
|
+
cleanup_local_stream(session_id)
|
241
|
+
end
|
242
|
+
end
|
243
|
+
|
244
|
+
@session_store.route_message_to_session(session_id, data)
|
245
|
+
end
|
246
|
+
|
247
|
+
def cleanup_session(session_id)
|
248
|
+
cleanup_local_stream(session_id)
|
249
|
+
@session_store.cleanup_session(session_id)
|
250
|
+
end
|
251
|
+
|
252
|
+
def setup_redis_subscriber
|
253
|
+
Thread.new do
|
254
|
+
@session_store.subscribe_to_server(@server_instance) do |data|
|
255
|
+
session_id = data["session_id"]
|
256
|
+
message = data["message"]
|
257
|
+
|
258
|
+
if @local_streams[session_id]
|
259
|
+
begin
|
260
|
+
send_to_stream(@local_streams[session_id], message)
|
261
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
262
|
+
cleanup_local_stream(session_id)
|
263
|
+
end
|
264
|
+
end
|
265
|
+
end
|
266
|
+
rescue => e
|
267
|
+
@configuration.logger.error("Redis subscriber error", error: e.message, backtrace: e.backtrace.first(5))
|
268
|
+
sleep 5
|
269
|
+
retry
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
def has_active_streams?
|
274
|
+
@local_streams.any?
|
275
|
+
end
|
276
|
+
|
277
|
+
def deliver_to_active_streams(notification)
|
278
|
+
@local_streams.each do |session_id, stream|
|
279
|
+
send_to_stream(stream, notification)
|
280
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
281
|
+
cleanup_local_stream(session_id)
|
282
|
+
end
|
283
|
+
end
|
284
|
+
|
285
|
+
def flush_notifications_to_stream(stream)
|
286
|
+
while (notification = @notification_queue.shift)
|
287
|
+
send_to_stream(stream, notification)
|
288
|
+
end
|
289
|
+
end
|
290
|
+
end
|
291
|
+
end
|
@@ -2,11 +2,13 @@ require "json-schema"
|
|
2
2
|
|
3
3
|
module ModelContextProtocol
|
4
4
|
class Server::Tool
|
5
|
-
attr_reader :params
|
5
|
+
attr_reader :params, :context, :logger
|
6
6
|
|
7
|
-
def initialize(params)
|
7
|
+
def initialize(params, logger, context = {})
|
8
8
|
validate!(params)
|
9
9
|
@params = params
|
10
|
+
@context = context
|
11
|
+
@logger = logger
|
10
12
|
end
|
11
13
|
|
12
14
|
def call
|
@@ -90,8 +92,8 @@ module ModelContextProtocol
|
|
90
92
|
subclass.instance_variable_set(:@input_schema, @input_schema)
|
91
93
|
end
|
92
94
|
|
93
|
-
def call(params)
|
94
|
-
new(params).call
|
95
|
+
def call(params, logger, context = {})
|
96
|
+
new(params, logger, context).call
|
95
97
|
rescue JSON::Schema::ValidationError => validation_error
|
96
98
|
raise ModelContextProtocol::Server::ParameterValidationError, validation_error.message
|
97
99
|
rescue ModelContextProtocol::Server::ResponseArgumentsError => response_arguments_error
|
@@ -19,13 +19,25 @@ module ModelContextProtocol
|
|
19
19
|
|
20
20
|
def start
|
21
21
|
configuration.validate!
|
22
|
-
|
23
|
-
|
22
|
+
|
23
|
+
transport = case configuration.transport_type
|
24
|
+
when :stdio, nil
|
25
|
+
StdioTransport.new(router: @router, configuration: @configuration)
|
26
|
+
when :streamable_http
|
27
|
+
StreamableHttpTransport.new(
|
28
|
+
router: @router,
|
29
|
+
configuration: @configuration
|
30
|
+
)
|
31
|
+
else
|
32
|
+
raise ArgumentError, "Unknown transport: #{configuration.transport_type}"
|
33
|
+
end
|
34
|
+
|
35
|
+
transport.handle
|
24
36
|
end
|
25
37
|
|
26
38
|
private
|
27
39
|
|
28
|
-
PROTOCOL_VERSION = "
|
40
|
+
PROTOCOL_VERSION = "2025-06-18".freeze
|
29
41
|
private_constant :PROTOCOL_VERSION
|
30
42
|
|
31
43
|
InitializeResponse = Data.define(:protocol_version, :capabilities, :server_info) do
|
@@ -44,6 +56,12 @@ module ModelContextProtocol
|
|
44
56
|
end
|
45
57
|
end
|
46
58
|
|
59
|
+
LoggingSetLevelResponse = Data.define do
|
60
|
+
def serialized
|
61
|
+
{}
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
47
65
|
def map_handlers
|
48
66
|
router.map("initialize") do |_message|
|
49
67
|
InitializeResponse[
|
@@ -60,6 +78,17 @@ module ModelContextProtocol
|
|
60
78
|
PingResponse[]
|
61
79
|
end
|
62
80
|
|
81
|
+
router.map("logging/setLevel") do |message|
|
82
|
+
level = message["params"]["level"]
|
83
|
+
|
84
|
+
unless Configuration::VALID_LOG_LEVELS.include?(level)
|
85
|
+
raise ParameterValidationError, "Invalid log level: #{level}. Valid levels are: #{Configuration::VALID_LOG_LEVELS.join(", ")}"
|
86
|
+
end
|
87
|
+
|
88
|
+
configuration.logger.set_mcp_level(level)
|
89
|
+
LoggingSetLevelResponse[]
|
90
|
+
end
|
91
|
+
|
63
92
|
router.map("completion/complete") do |message|
|
64
93
|
type = message["params"]["ref"]["type"]
|
65
94
|
|
@@ -94,7 +123,7 @@ module ModelContextProtocol
|
|
94
123
|
raise ModelContextProtocol::Server::ParameterValidationError, "resource not found for #{uri}"
|
95
124
|
end
|
96
125
|
|
97
|
-
resource.call
|
126
|
+
resource.call(configuration.logger, configuration.context)
|
98
127
|
end
|
99
128
|
|
100
129
|
router.map("resources/templates/list") do |message|
|
@@ -106,7 +135,12 @@ module ModelContextProtocol
|
|
106
135
|
end
|
107
136
|
|
108
137
|
router.map("prompts/get") do |message|
|
109
|
-
|
138
|
+
arguments = message["params"]["arguments"]
|
139
|
+
symbolized_arguments = arguments.transform_keys(&:to_sym)
|
140
|
+
configuration
|
141
|
+
.registry
|
142
|
+
.find_prompt(message["params"]["name"])
|
143
|
+
.call(symbolized_arguments, configuration.logger, configuration.context)
|
110
144
|
end
|
111
145
|
|
112
146
|
router.map("tools/list") do
|
@@ -114,7 +148,12 @@ module ModelContextProtocol
|
|
114
148
|
end
|
115
149
|
|
116
150
|
router.map("tools/call") do |message|
|
117
|
-
|
151
|
+
arguments = message["params"]["arguments"]
|
152
|
+
symbolized_arguments = arguments.transform_keys(&:to_sym)
|
153
|
+
configuration
|
154
|
+
.registry
|
155
|
+
.find_tool(message["params"]["name"])
|
156
|
+
.call(symbolized_arguments, configuration.logger, configuration.context)
|
118
157
|
end
|
119
158
|
end
|
120
159
|
|
data/tasks/templates/dev.erb
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
#!/usr/bin/env <%= @ruby_path %>
|
2
2
|
|
3
3
|
require "bundler/setup"
|
4
|
+
require "securerandom"
|
4
5
|
require_relative "../lib/model_context_protocol"
|
5
6
|
|
6
7
|
Dir[File.join(__dir__, "../spec/support/**/*.rb")].each { |file| require file }
|
@@ -8,7 +9,15 @@ Dir[File.join(__dir__, "../spec/support/**/*.rb")].each { |file| require file }
|
|
8
9
|
server = ModelContextProtocol::Server.new do |config|
|
9
10
|
config.name = "MCP Development Server"
|
10
11
|
config.version = "1.0.0"
|
11
|
-
config.
|
12
|
+
config.logging_enabled = true
|
13
|
+
|
14
|
+
config.set_environment_variable("MCP_ENV", "development")
|
15
|
+
|
16
|
+
config.context = {
|
17
|
+
user_id: "123456",
|
18
|
+
request_id: SecureRandom.uuid
|
19
|
+
}
|
20
|
+
|
12
21
|
config.registry = ModelContextProtocol::Server::Registry.new do
|
13
22
|
prompts list_changed: true do
|
14
23
|
register TestPrompt
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: model-context-protocol-rb
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.3.
|
4
|
+
version: 0.3.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Dick Davis
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-
|
11
|
+
date: 2025-09-02 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: json-schema
|
@@ -58,12 +58,15 @@ files:
|
|
58
58
|
- lib/model_context_protocol/server.rb
|
59
59
|
- lib/model_context_protocol/server/completion.rb
|
60
60
|
- lib/model_context_protocol/server/configuration.rb
|
61
|
+
- lib/model_context_protocol/server/mcp_logger.rb
|
61
62
|
- lib/model_context_protocol/server/prompt.rb
|
62
63
|
- lib/model_context_protocol/server/registry.rb
|
63
64
|
- lib/model_context_protocol/server/resource.rb
|
64
65
|
- lib/model_context_protocol/server/resource_template.rb
|
65
66
|
- lib/model_context_protocol/server/router.rb
|
67
|
+
- lib/model_context_protocol/server/session_store.rb
|
66
68
|
- lib/model_context_protocol/server/stdio_transport.rb
|
69
|
+
- lib/model_context_protocol/server/streamable_http_transport.rb
|
67
70
|
- lib/model_context_protocol/server/tool.rb
|
68
71
|
- lib/model_context_protocol/version.rb
|
69
72
|
- tasks/mcp.rake
|