model-context-protocol-rb 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -2
- data/README.md +174 -978
- data/lib/model_context_protocol/rspec/helpers.rb +54 -0
- data/lib/model_context_protocol/rspec/matchers/be_mcp_error_response.rb +123 -0
- data/lib/model_context_protocol/rspec/matchers/be_valid_mcp_class.rb +103 -0
- data/lib/model_context_protocol/rspec/matchers/be_valid_mcp_prompt_response.rb +126 -0
- data/lib/model_context_protocol/rspec/matchers/be_valid_mcp_resource_response.rb +121 -0
- data/lib/model_context_protocol/rspec/matchers/be_valid_mcp_tool_response.rb +135 -0
- data/lib/model_context_protocol/rspec/matchers/have_audio_content.rb +109 -0
- data/lib/model_context_protocol/rspec/matchers/have_embedded_resource_content.rb +150 -0
- data/lib/model_context_protocol/rspec/matchers/have_image_content.rb +109 -0
- data/lib/model_context_protocol/rspec/matchers/have_message_count.rb +87 -0
- data/lib/model_context_protocol/rspec/matchers/have_message_with_role.rb +152 -0
- data/lib/model_context_protocol/rspec/matchers/have_resource_annotations.rb +135 -0
- data/lib/model_context_protocol/rspec/matchers/have_resource_blob.rb +108 -0
- data/lib/model_context_protocol/rspec/matchers/have_resource_link_content.rb +138 -0
- data/lib/model_context_protocol/rspec/matchers/have_resource_mime_type.rb +103 -0
- data/lib/model_context_protocol/rspec/matchers/have_resource_text.rb +112 -0
- data/lib/model_context_protocol/rspec/matchers/have_structured_content.rb +88 -0
- data/lib/model_context_protocol/rspec/matchers/have_text_content.rb +113 -0
- data/lib/model_context_protocol/rspec/matchers.rb +31 -0
- data/lib/model_context_protocol/rspec.rb +23 -0
- data/lib/model_context_protocol/server/client_logger.rb +1 -1
- data/lib/model_context_protocol/server/configuration.rb +195 -91
- data/lib/model_context_protocol/server/content_helpers.rb +1 -1
- data/lib/model_context_protocol/server/prompt.rb +0 -14
- data/lib/model_context_protocol/server/redis_client_proxy.rb +2 -14
- data/lib/model_context_protocol/server/redis_config.rb +5 -7
- data/lib/model_context_protocol/server/redis_pool_manager.rb +10 -13
- data/lib/model_context_protocol/server/registry.rb +8 -0
- data/lib/model_context_protocol/server/router.rb +279 -4
- data/lib/model_context_protocol/server/server_logger.rb +5 -2
- data/lib/model_context_protocol/server/stdio_configuration.rb +114 -0
- data/lib/model_context_protocol/server/stdio_transport/request_store.rb +0 -41
- data/lib/model_context_protocol/server/streamable_http_configuration.rb +218 -0
- data/lib/model_context_protocol/server/streamable_http_transport/event_counter.rb +0 -13
- data/lib/model_context_protocol/server/streamable_http_transport/notification_queue.rb +0 -41
- data/lib/model_context_protocol/server/streamable_http_transport/request_store.rb +0 -103
- data/lib/model_context_protocol/server/streamable_http_transport/server_request_store.rb +0 -64
- data/lib/model_context_protocol/server/streamable_http_transport/session_message_queue.rb +0 -58
- data/lib/model_context_protocol/server/streamable_http_transport/session_store.rb +17 -31
- data/lib/model_context_protocol/server/streamable_http_transport/stream_registry.rb +0 -34
- data/lib/model_context_protocol/server/streamable_http_transport.rb +192 -56
- data/lib/model_context_protocol/server/tool.rb +67 -1
- data/lib/model_context_protocol/server.rb +203 -262
- data/lib/model_context_protocol/version.rb +1 -1
- data/lib/model_context_protocol.rb +4 -1
- data/lib/puma/plugin/mcp.rb +39 -0
- data/tasks/mcp.rake +26 -0
- data/tasks/templates/dev-http-puma.erb +251 -0
- data/tasks/templates/dev-http.erb +166 -184
- data/tasks/templates/dev.erb +29 -7
- metadata +26 -2
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ModelContextProtocol
|
|
4
|
+
module RSpec
|
|
5
|
+
module Matchers
|
|
6
|
+
# Matcher that validates a tool response contains specific text content.
|
|
7
|
+
#
|
|
8
|
+
# @example With exact string match
|
|
9
|
+
# expect(response).to have_text_content("Hello, World!")
|
|
10
|
+
#
|
|
11
|
+
# @example With regex match
|
|
12
|
+
# expect(response).to have_text_content(/doubled is \d+/)
|
|
13
|
+
#
|
|
14
|
+
def have_text_content(expected_text)
|
|
15
|
+
HaveTextContent.new(expected_text)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class HaveTextContent
|
|
19
|
+
def initialize(expected_text)
|
|
20
|
+
@expected_text = expected_text
|
|
21
|
+
@failure_reasons = []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def matches?(actual)
|
|
25
|
+
@actual = actual
|
|
26
|
+
@failure_reasons = []
|
|
27
|
+
@serialized = serialize_response(actual)
|
|
28
|
+
|
|
29
|
+
return false if @serialized.nil?
|
|
30
|
+
|
|
31
|
+
validate_has_content &&
|
|
32
|
+
validate_has_text_content
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def failure_message
|
|
36
|
+
"expected response to have text content matching #{@expected_text.inspect}, but:\n" +
|
|
37
|
+
@failure_reasons.map { |reason| " - #{reason}" }.join("\n")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def failure_message_when_negated
|
|
41
|
+
"expected response not to have text content matching #{@expected_text.inspect}, but it did"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def description
|
|
45
|
+
"have text content matching #{@expected_text.inspect}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def serialize_response(response)
|
|
51
|
+
if response.respond_to?(:serialized)
|
|
52
|
+
response.serialized
|
|
53
|
+
elsif response.is_a?(Hash)
|
|
54
|
+
response
|
|
55
|
+
else
|
|
56
|
+
@failure_reasons << "response must respond to :serialized or be a Hash"
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def validate_has_content
|
|
62
|
+
@content = @serialized[:content] || @serialized["content"]
|
|
63
|
+
|
|
64
|
+
unless @content
|
|
65
|
+
@failure_reasons << "response does not have :content key"
|
|
66
|
+
return false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
unless @content.is_a?(Array)
|
|
70
|
+
@failure_reasons << "content must be an Array"
|
|
71
|
+
return false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def validate_has_text_content
|
|
78
|
+
text_items = @content.select do |item|
|
|
79
|
+
type = item[:type] || item["type"]
|
|
80
|
+
type == "text"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
if text_items.empty?
|
|
84
|
+
@failure_reasons << "no text content found in response"
|
|
85
|
+
return false
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
matching_item = text_items.find do |item|
|
|
89
|
+
text = item[:text] || item["text"]
|
|
90
|
+
text_matches?(text)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
unless matching_item
|
|
94
|
+
actual_texts = text_items.map { |item| item[:text] || item["text"] }
|
|
95
|
+
@failure_reasons << "no text content matched, found: #{actual_texts.inspect}"
|
|
96
|
+
return false
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def text_matches?(text)
|
|
103
|
+
case @expected_text
|
|
104
|
+
when Regexp
|
|
105
|
+
@expected_text.match?(text)
|
|
106
|
+
else
|
|
107
|
+
text.include?(@expected_text.to_s)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require_relative "matchers/be_valid_mcp_class"
|
|
2
|
+
|
|
3
|
+
# Tool response matchers
|
|
4
|
+
require_relative "matchers/be_valid_mcp_tool_response"
|
|
5
|
+
require_relative "matchers/have_structured_content"
|
|
6
|
+
require_relative "matchers/have_text_content"
|
|
7
|
+
require_relative "matchers/have_image_content"
|
|
8
|
+
require_relative "matchers/have_audio_content"
|
|
9
|
+
require_relative "matchers/have_embedded_resource_content"
|
|
10
|
+
require_relative "matchers/have_resource_link_content"
|
|
11
|
+
require_relative "matchers/be_mcp_error_response"
|
|
12
|
+
|
|
13
|
+
# Prompt response matchers
|
|
14
|
+
require_relative "matchers/be_valid_mcp_prompt_response"
|
|
15
|
+
require_relative "matchers/have_message_with_role"
|
|
16
|
+
require_relative "matchers/have_message_count"
|
|
17
|
+
|
|
18
|
+
# Resource response matchers
|
|
19
|
+
require_relative "matchers/be_valid_mcp_resource_response"
|
|
20
|
+
require_relative "matchers/have_resource_text"
|
|
21
|
+
require_relative "matchers/have_resource_blob"
|
|
22
|
+
require_relative "matchers/have_resource_mime_type"
|
|
23
|
+
require_relative "matchers/have_resource_annotations"
|
|
24
|
+
|
|
25
|
+
module ModelContextProtocol
|
|
26
|
+
module RSpec
|
|
27
|
+
module Matchers
|
|
28
|
+
# Include all matchers when this module is included
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require "model_context_protocol"
|
|
2
|
+
require_relative "rspec/matchers"
|
|
3
|
+
require_relative "rspec/helpers"
|
|
4
|
+
|
|
5
|
+
module ModelContextProtocol
|
|
6
|
+
module RSpec
|
|
7
|
+
# Convenience method to configure RSpec with MCP matchers and helpers.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# # In spec_helper.rb
|
|
11
|
+
# require "model_context_protocol/rspec"
|
|
12
|
+
# ModelContextProtocol::RSpec.configure!
|
|
13
|
+
#
|
|
14
|
+
# @return [void]
|
|
15
|
+
def self.configure!
|
|
16
|
+
::RSpec.configure do |config|
|
|
17
|
+
config.include ModelContextProtocol::RSpec::Matchers
|
|
18
|
+
config.include ModelContextProtocol::RSpec::Helpers, type: :mcp
|
|
19
|
+
config.include ModelContextProtocol::RSpec::Helpers, file_path: %r{spec/mcp/}
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -1,65 +1,149 @@
|
|
|
1
1
|
module ModelContextProtocol
|
|
2
|
+
# Base settings container for MCP servers with two concrete subclasses:
|
|
3
|
+
# - {StdioConfiguration} for standalone scripts using stdin/stdout
|
|
4
|
+
# - {StreamableHttpConfiguration} for Rack applications using streamable HTTP with Redis
|
|
5
|
+
#
|
|
6
|
+
# Server.rb factory methods (with_stdio_transport, with_streamable_http_transport)
|
|
7
|
+
# instantiate the appropriate subclass, yield it to a block for population, validate
|
|
8
|
+
# it, then pass it to Router.new. Router reads pagination settings via pagination_options
|
|
9
|
+
# and queries transport capabilities via supports_list_changed? and apply_environment_variables?.
|
|
10
|
+
#
|
|
11
|
+
# The base class provides shared attributes (name, version, registry, pagination) and
|
|
12
|
+
# validation logic, while subclasses override transport_type and validate_transport! to
|
|
13
|
+
# enforce transport-specific constraints.
|
|
2
14
|
class Server::Configuration
|
|
3
|
-
#
|
|
15
|
+
# Signals that the server's identifying name is missing or not a String.
|
|
16
|
+
# Raised by validate! when name is nil or non-String.
|
|
4
17
|
class InvalidServerNameError < StandardError; end
|
|
5
18
|
|
|
6
|
-
#
|
|
19
|
+
# Signals that the server's version string is missing or not a String.
|
|
20
|
+
# Raised by validate! when version is nil or non-String.
|
|
7
21
|
class InvalidServerVersionError < StandardError; end
|
|
8
22
|
|
|
9
|
-
#
|
|
23
|
+
# Signals that the optional UI title is non-nil but not a String.
|
|
24
|
+
# Raised by validate_title! when title is set to a non-String value.
|
|
10
25
|
class InvalidServerTitleError < StandardError; end
|
|
11
26
|
|
|
12
|
-
#
|
|
27
|
+
# Signals that the optional LLM instructions are non-nil but not a String.
|
|
28
|
+
# Raised by validate_instructions! when instructions is set to a non-String value.
|
|
13
29
|
class InvalidServerInstructionsError < StandardError; end
|
|
14
30
|
|
|
15
|
-
#
|
|
31
|
+
# Signals that the registry is missing or not a Registry instance.
|
|
32
|
+
# Raised by validate! when registry is nil or not a ModelContextProtocol::Server::Registry.
|
|
16
33
|
class InvalidRegistryError < StandardError; end
|
|
17
34
|
|
|
18
|
-
#
|
|
35
|
+
# Signals that a required environment variable is absent (stdio transport only).
|
|
36
|
+
# Raised by StdioConfiguration#validate_environment_variables! when a variable
|
|
37
|
+
# declared via require_environment_variable is not set.
|
|
19
38
|
class MissingRequiredEnvironmentVariable < StandardError; end
|
|
20
39
|
|
|
21
|
-
#
|
|
40
|
+
# Signals transport-specific validation failure (Redis missing for HTTP, stdout conflict for stdio).
|
|
41
|
+
# Raised by subclass implementations of validate_transport! when prerequisites are unmet.
|
|
22
42
|
class InvalidTransportError < StandardError; end
|
|
23
43
|
|
|
24
|
-
#
|
|
44
|
+
# Signals that pagination settings are invalid (negative sizes, out-of-range defaults).
|
|
45
|
+
# Raised by validate_pagination! when default_page_size exceeds max_page_size or values are non-positive.
|
|
25
46
|
class InvalidPaginationError < StandardError; end
|
|
26
47
|
|
|
27
|
-
|
|
28
|
-
|
|
48
|
+
# @!attribute [rw] name
|
|
49
|
+
# @return [String] the server's identifying name (sent in initialize response serverInfo.name)
|
|
50
|
+
attr_accessor :name
|
|
51
|
+
|
|
52
|
+
# @!attribute [rw] version
|
|
53
|
+
# @return [String] the server's version string (sent in initialize response serverInfo.version)
|
|
54
|
+
attr_accessor :version
|
|
55
|
+
|
|
56
|
+
# @!attribute [rw] pagination
|
|
57
|
+
# @return [Hash, false, nil] pagination settings or false to disable;
|
|
58
|
+
# Router calls pagination_options to extract default_page_size, max_page_size, and cursor_ttl
|
|
59
|
+
# when handling list requests (resources/list, prompts/list, tools/list).
|
|
60
|
+
# Accepts Hash with :enabled, :default_page_size, :max_page_size, :cursor_ttl keys, or false to disable.
|
|
61
|
+
attr_accessor :pagination
|
|
62
|
+
|
|
63
|
+
# @!attribute [rw] title
|
|
64
|
+
# @return [String, nil] optional human-readable display title for Claude Desktop UI
|
|
65
|
+
# (sent in initialize response serverInfo.title if present)
|
|
66
|
+
attr_accessor :title
|
|
67
|
+
|
|
68
|
+
# @!attribute [rw] instructions
|
|
69
|
+
# @return [String, nil] optional guidance for LLMs on how to use the server
|
|
70
|
+
# (sent in initialize response instructions field if present)
|
|
71
|
+
attr_accessor :instructions
|
|
72
|
+
|
|
73
|
+
# @!attribute [r] client_logger
|
|
74
|
+
# @return [ClientLogger] logger for sending notifications/message to MCP clients via JSON-RPC;
|
|
75
|
+
# Router passes this to prompts, resources, and tools so they can log to the client
|
|
76
|
+
attr_reader :client_logger
|
|
77
|
+
|
|
78
|
+
# Lazily-built Ruby Logger for server-side diagnostics (not sent to clients).
|
|
79
|
+
# Reads from GlobalConfig::ServerLogging on first access so that
|
|
80
|
+
# Server.configure_server_logging can be called before or after the factory method.
|
|
81
|
+
#
|
|
82
|
+
# @return [ServerLogger] writes to stderr by default, or configured destination via configure_server_logging
|
|
83
|
+
def server_logger
|
|
84
|
+
@server_logger ||= begin
|
|
85
|
+
params = if ModelContextProtocol::Server::GlobalConfig::ServerLogging.configured?
|
|
86
|
+
ModelContextProtocol::Server::GlobalConfig::ServerLogging.logger_params
|
|
87
|
+
else
|
|
88
|
+
{}
|
|
89
|
+
end
|
|
90
|
+
ModelContextProtocol::Server::ServerLogger.new(**params)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
29
93
|
|
|
94
|
+
# Initialize shared attributes and loggers for any configuration subclass.
|
|
95
|
+
# ClientLogger queues messages until a transport connects; ServerLogger is built
|
|
96
|
+
# lazily on first access via #server_logger.
|
|
30
97
|
def initialize
|
|
31
98
|
@client_logger = ModelContextProtocol::Server::ClientLogger.new(
|
|
32
99
|
logger_name: "server",
|
|
33
100
|
level: "info"
|
|
34
101
|
)
|
|
35
|
-
|
|
36
|
-
server_logger_params = if ModelContextProtocol::Server::GlobalConfig::ServerLogging.configured?
|
|
37
|
-
ModelContextProtocol::Server::GlobalConfig::ServerLogging.logger_params
|
|
38
|
-
else
|
|
39
|
-
{}
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
@server_logger = ModelContextProtocol::Server::ServerLogger.new(**server_logger_params)
|
|
43
102
|
end
|
|
44
103
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
104
|
+
# Create and store a Registry from a block defining prompts, resources, and tools.
|
|
105
|
+
# Router queries the resulting registry to handle resources/list, tools/call, etc.
|
|
106
|
+
#
|
|
107
|
+
# @yieldparam (see Registry.new)
|
|
108
|
+
# @return [ModelContextProtocol::Server::Registry] the created registry
|
|
109
|
+
# @example
|
|
110
|
+
# config.registry do
|
|
111
|
+
# tools { register MyTool }
|
|
112
|
+
# end
|
|
113
|
+
def registry(&block)
|
|
114
|
+
return @registry unless block
|
|
115
|
+
|
|
116
|
+
@registry = ModelContextProtocol::Server::Registry.new(&block)
|
|
52
117
|
end
|
|
53
118
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
119
|
+
# Identify the transport layer for this configuration.
|
|
120
|
+
# Subclasses return :stdio or :streamable_http; Server.start uses this to
|
|
121
|
+
# instantiate the correct Transport class (StdioTransport or StreamableHttpTransport).
|
|
122
|
+
#
|
|
123
|
+
# @return [Symbol, nil] nil in the base class (never instantiated directly)
|
|
124
|
+
def transport_type = nil
|
|
125
|
+
|
|
126
|
+
# Determine whether the transport supports notifications/resources/list_changed
|
|
127
|
+
# and notifications/tools/list_changed. Router queries this when building the
|
|
128
|
+
# initialize response capabilities hash (adding listChanged: true to prompts/resources/tools).
|
|
129
|
+
# Only HTTP transport returns true (stdio can't push unsolicited notifications).
|
|
130
|
+
#
|
|
131
|
+
# @return [Boolean] false in base class and StdioConfiguration, true in StreamableHttpConfiguration
|
|
132
|
+
def supports_list_changed? = false
|
|
62
133
|
|
|
134
|
+
# Determine whether Router should modify ENV before executing handlers.
|
|
135
|
+
# StdioConfiguration returns true because stdin/stdout scripts run single-threaded
|
|
136
|
+
# and ENV mutation is safe. StreamableHttpConfiguration returns false because
|
|
137
|
+
# ENV is global and modifying it in a multi-threaded Rack server creates race conditions.
|
|
138
|
+
#
|
|
139
|
+
# @return [Boolean] false in base class, overridden by subclasses
|
|
140
|
+
def apply_environment_variables? = false
|
|
141
|
+
|
|
142
|
+
# Check whether pagination is active for list responses (resources/list, prompts/list, tools/list).
|
|
143
|
+
# Router calls this before extracting pagination params; if false, it returns unpaginated results.
|
|
144
|
+
# Enabled by default (nil or true), or when pagination Hash has enabled != false.
|
|
145
|
+
#
|
|
146
|
+
# @return [Boolean] true unless pagination is explicitly set to false
|
|
63
147
|
def pagination_enabled?
|
|
64
148
|
return true if pagination.nil?
|
|
65
149
|
|
|
@@ -73,6 +157,12 @@ module ModelContextProtocol
|
|
|
73
157
|
end
|
|
74
158
|
end
|
|
75
159
|
|
|
160
|
+
# Extract normalized pagination settings for Router to pass to Pagination.extract_pagination_params.
|
|
161
|
+
# Router uses default_page_size and max_page_size to validate cursor and page size params from the client,
|
|
162
|
+
# and cursor_ttl to configure how long the Pagination module stores cursor state in memory.
|
|
163
|
+
#
|
|
164
|
+
# @return [Hash] pagination parameters with keys :enabled, :default_page_size, :max_page_size, :cursor_ttl;
|
|
165
|
+
# defaults are 100, 1000, and 3600 (1 hour) respectively
|
|
76
166
|
def pagination_options
|
|
77
167
|
case pagination
|
|
78
168
|
when Hash
|
|
@@ -94,6 +184,19 @@ module ModelContextProtocol
|
|
|
94
184
|
end
|
|
95
185
|
end
|
|
96
186
|
|
|
187
|
+
# Verify all required attributes and transport-specific constraints.
|
|
188
|
+
# Called by Server.build_server (the factory method's internal logic) after yielding
|
|
189
|
+
# the configuration block but before constructing the Router. Ensures the configuration
|
|
190
|
+
# is complete and internally consistent.
|
|
191
|
+
#
|
|
192
|
+
# @raise [InvalidServerNameError] if name is nil or non-String
|
|
193
|
+
# @raise [InvalidRegistryError] if registry is nil or not a Registry instance
|
|
194
|
+
# @raise [InvalidServerVersionError] if version is nil or non-String
|
|
195
|
+
# @raise [InvalidTransportError] if transport prerequisites fail (subclass-specific)
|
|
196
|
+
# @raise [InvalidPaginationError] if page sizes are negative or out of range
|
|
197
|
+
# @raise [InvalidServerTitleError] if title is non-nil but not a String
|
|
198
|
+
# @raise [InvalidServerInstructionsError] if instructions is non-nil but not a String
|
|
199
|
+
# @return [void]
|
|
97
200
|
def validate!
|
|
98
201
|
raise InvalidServerNameError unless valid_name?
|
|
99
202
|
raise InvalidRegistryError unless valid_registry?
|
|
@@ -103,93 +206,84 @@ module ModelContextProtocol
|
|
|
103
206
|
validate_pagination!
|
|
104
207
|
validate_title!
|
|
105
208
|
validate_instructions!
|
|
106
|
-
validate_environment_variables!
|
|
107
|
-
validate_server_logging_transport_constraints!
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def environment_variables
|
|
111
|
-
@environment_variables ||= {}
|
|
112
209
|
end
|
|
113
210
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def require_environment_variable(key)
|
|
119
|
-
required_environment_variables << key.to_s.upcase
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
# Programatically set an environment variable - useful if an alternative
|
|
123
|
-
# to environment variables is used for security purposes. Despite being
|
|
124
|
-
# more like 'configuration variables', these are called environment variables
|
|
125
|
-
# to align with the Model Context Protocol terminology.
|
|
126
|
-
#
|
|
127
|
-
# see: https://modelcontextprotocol.io/docs/tools/debugging#environment-variables
|
|
211
|
+
# Access server-wide key-value storage merged with per-request session_context by Router.
|
|
212
|
+
# Router.effective_context merges this with Thread.current[:mcp_context][:session_context],
|
|
213
|
+
# then passes the result to prompts, resources, and tools so they can access both
|
|
214
|
+
# server-level (shared across all requests) and session-level (specific to HTTP session) data.
|
|
128
215
|
#
|
|
129
|
-
# @
|
|
130
|
-
# @param value [String] The value to set the environment variable to
|
|
131
|
-
def set_environment_variable(key, value)
|
|
132
|
-
environment_variables[key.to_s.upcase] = value
|
|
133
|
-
end
|
|
134
|
-
|
|
216
|
+
# @return [Hash] the lazily-initialized context hash (defaults to {})
|
|
135
217
|
def context
|
|
136
218
|
@context ||= {}
|
|
137
219
|
end
|
|
138
220
|
|
|
221
|
+
# Replace the server-wide context with a new hash.
|
|
222
|
+
# Router reads this via effective_context, merging it with session-level data before
|
|
223
|
+
# passing to handler implementations.
|
|
224
|
+
#
|
|
225
|
+
# @param context_hash [Hash] the new context to store (defaults to {})
|
|
226
|
+
# @return [Hash] the stored context
|
|
139
227
|
def context=(context_hash = {})
|
|
140
228
|
@context = context_hash
|
|
141
229
|
end
|
|
142
230
|
|
|
143
231
|
private
|
|
144
232
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
233
|
+
# Template method for subclass-specific transport validation.
|
|
234
|
+
# StdioConfiguration checks that required environment variables are set and that
|
|
235
|
+
# server_logger isn't writing to stdout (which would corrupt the stdio protocol).
|
|
236
|
+
# StreamableHttpConfiguration validates that redis_url is a valid Redis URL.
|
|
237
|
+
#
|
|
238
|
+
# @raise [InvalidTransportError] when transport prerequisites are unmet
|
|
239
|
+
# @raise [MissingRequiredEnvironmentVariable] when stdio transport requires an unset variable
|
|
240
|
+
# @return [void]
|
|
241
|
+
def validate_transport!
|
|
242
|
+
# Template method — subclasses override to add transport-specific validations
|
|
153
243
|
end
|
|
154
244
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
245
|
+
# Template method for subclass-specific transport setup (side effects).
|
|
246
|
+
# Called by Server.build_server after validate! passes. Separated from validate_transport!
|
|
247
|
+
# because validation should be pure (no side effects), while setup performs actions like
|
|
248
|
+
# creating connection pools.
|
|
249
|
+
# StreamableHttpConfiguration overrides this to configure the Redis connection pool.
|
|
250
|
+
#
|
|
251
|
+
# @return [void]
|
|
252
|
+
def setup_transport!
|
|
253
|
+
# Template method — subclasses override to perform transport-specific setup
|
|
160
254
|
end
|
|
161
255
|
|
|
256
|
+
# Check that name attribute is a non-nil String.
|
|
257
|
+
# Called by validate! to ensure the initialize response can be constructed.
|
|
258
|
+
#
|
|
259
|
+
# @return [Boolean] true if name is a String
|
|
162
260
|
def valid_name?
|
|
163
261
|
name&.is_a?(String)
|
|
164
262
|
end
|
|
165
263
|
|
|
264
|
+
# Check that registry attribute is a Registry instance.
|
|
265
|
+
# Called by validate! to ensure Router can query tools, prompts, and resources.
|
|
266
|
+
#
|
|
267
|
+
# @return [Boolean] true if registry is a ModelContextProtocol::Server::Registry
|
|
166
268
|
def valid_registry?
|
|
167
269
|
registry&.is_a?(ModelContextProtocol::Server::Registry)
|
|
168
270
|
end
|
|
169
271
|
|
|
272
|
+
# Check that version attribute is a non-nil String.
|
|
273
|
+
# Called by validate! to ensure the initialize response can be constructed.
|
|
274
|
+
#
|
|
275
|
+
# @return [Boolean] true if version is a String
|
|
170
276
|
def valid_version?
|
|
171
277
|
version&.is_a?(String)
|
|
172
278
|
end
|
|
173
279
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
raise InvalidTransportError, "Unknown transport type: #{transport_type}" if transport_type
|
|
182
|
-
end
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
def validate_streamable_http_transport!
|
|
186
|
-
unless ModelContextProtocol::Server::RedisConfig.configured?
|
|
187
|
-
raise InvalidTransportError,
|
|
188
|
-
"streamable_http transport requires Redis configuration. " \
|
|
189
|
-
"Call ModelContextProtocol::Server.configure_redis in an initializer."
|
|
190
|
-
end
|
|
191
|
-
end
|
|
192
|
-
|
|
280
|
+
# Verify pagination settings are internally consistent (page sizes positive, defaults in range).
|
|
281
|
+
# Called by validate! only when pagination_enabled? is true; skipped if pagination is false.
|
|
282
|
+
# Ensures Router won't pass invalid params to Pagination.extract_pagination_params.
|
|
283
|
+
#
|
|
284
|
+
# @raise [InvalidPaginationError] if max_page_size <= 0, default_page_size <= 0,
|
|
285
|
+
# default_page_size > max_page_size, or cursor_ttl < 0
|
|
286
|
+
# @return [void]
|
|
193
287
|
def validate_pagination!
|
|
194
288
|
return unless pagination_enabled?
|
|
195
289
|
|
|
@@ -208,6 +302,11 @@ module ModelContextProtocol
|
|
|
208
302
|
end
|
|
209
303
|
end
|
|
210
304
|
|
|
305
|
+
# Check that the optional title attribute is nil or a String.
|
|
306
|
+
# Called by validate! to ensure the initialize response serverInfo.title is valid.
|
|
307
|
+
#
|
|
308
|
+
# @raise [InvalidServerTitleError] if title is non-nil and not a String
|
|
309
|
+
# @return [void]
|
|
211
310
|
def validate_title!
|
|
212
311
|
return if title.nil?
|
|
213
312
|
return if title.is_a?(String)
|
|
@@ -215,6 +314,11 @@ module ModelContextProtocol
|
|
|
215
314
|
raise InvalidServerTitleError, "Server title must be a string"
|
|
216
315
|
end
|
|
217
316
|
|
|
317
|
+
# Check that the optional instructions attribute is nil or a String.
|
|
318
|
+
# Called by validate! to ensure the initialize response instructions field is valid.
|
|
319
|
+
#
|
|
320
|
+
# @raise [InvalidServerInstructionsError] if instructions is non-nil and not a String
|
|
321
|
+
# @return [void]
|
|
218
322
|
def validate_instructions!
|
|
219
323
|
return if instructions.nil?
|
|
220
324
|
return if instructions.is_a?(String)
|
|
@@ -70,20 +70,6 @@ module ModelContextProtocol
|
|
|
70
70
|
@defined_arguments.concat(definition_dsl.arguments)
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
-
def with_argument(&block)
|
|
74
|
-
@defined_arguments ||= []
|
|
75
|
-
|
|
76
|
-
argument_dsl = ArgumentDSL.new
|
|
77
|
-
argument_dsl.instance_eval(&block)
|
|
78
|
-
|
|
79
|
-
@defined_arguments << {
|
|
80
|
-
name: argument_dsl.name,
|
|
81
|
-
description: argument_dsl.description,
|
|
82
|
-
required: argument_dsl.required,
|
|
83
|
-
completion: argument_dsl.completion
|
|
84
|
-
}
|
|
85
|
-
end
|
|
86
|
-
|
|
87
73
|
def inherited(subclass)
|
|
88
74
|
subclass.instance_variable_set(:@name, @name)
|
|
89
75
|
subclass.instance_variable_set(:@description, @description)
|
|
@@ -39,8 +39,8 @@ module ModelContextProtocol
|
|
|
39
39
|
with_connection { |redis| redis.hset(key, *args) }
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
def
|
|
43
|
-
with_connection { |redis| redis.
|
|
42
|
+
def hmget(key, *fields)
|
|
43
|
+
with_connection { |redis| redis.hmget(key, *fields) }
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def lpush(key, *values)
|
|
@@ -67,10 +67,6 @@ module ModelContextProtocol
|
|
|
67
67
|
with_connection { |redis| redis.incr(key) }
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
def decr(key)
|
|
71
|
-
with_connection { |redis| redis.decr(key) }
|
|
72
|
-
end
|
|
73
|
-
|
|
74
70
|
def keys(pattern)
|
|
75
71
|
with_connection { |redis| redis.keys(pattern) }
|
|
76
72
|
end
|
|
@@ -101,14 +97,6 @@ module ModelContextProtocol
|
|
|
101
97
|
with_connection { |redis| redis.eval(script, keys: keys, argv: argv) }
|
|
102
98
|
end
|
|
103
99
|
|
|
104
|
-
def ping
|
|
105
|
-
with_connection { |redis| redis.ping }
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def flushdb
|
|
109
|
-
with_connection { |redis| redis.flushdb }
|
|
110
|
-
end
|
|
111
|
-
|
|
112
100
|
private
|
|
113
101
|
|
|
114
102
|
def with_connection(&block)
|
|
@@ -6,7 +6,7 @@ module ModelContextProtocol
|
|
|
6
6
|
|
|
7
7
|
class NotConfiguredError < StandardError
|
|
8
8
|
def initialize
|
|
9
|
-
super("Redis not configured.
|
|
9
|
+
super("Redis not configured. Set redis_url in the StreamableHttpConfiguration block.")
|
|
10
10
|
end
|
|
11
11
|
end
|
|
12
12
|
|
|
@@ -36,10 +36,6 @@ module ModelContextProtocol
|
|
|
36
36
|
instance.stats
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
def self.pool_manager
|
|
40
|
-
instance.manager
|
|
41
|
-
end
|
|
42
|
-
|
|
43
39
|
def initialize
|
|
44
40
|
reset!
|
|
45
41
|
end
|
|
@@ -53,7 +49,8 @@ module ModelContextProtocol
|
|
|
53
49
|
@manager = Server::RedisPoolManager.new(
|
|
54
50
|
redis_url: config.redis_url,
|
|
55
51
|
pool_size: config.pool_size,
|
|
56
|
-
pool_timeout: config.pool_timeout
|
|
52
|
+
pool_timeout: config.pool_timeout,
|
|
53
|
+
ssl_params: config.ssl_params
|
|
57
54
|
)
|
|
58
55
|
|
|
59
56
|
if config.enable_reaper
|
|
@@ -93,7 +90,7 @@ module ModelContextProtocol
|
|
|
93
90
|
|
|
94
91
|
class Configuration
|
|
95
92
|
attr_accessor :redis_url, :pool_size, :pool_timeout,
|
|
96
|
-
:enable_reaper, :reaper_interval, :idle_timeout
|
|
93
|
+
:enable_reaper, :reaper_interval, :idle_timeout, :ssl_params
|
|
97
94
|
|
|
98
95
|
def initialize
|
|
99
96
|
@redis_url = nil
|
|
@@ -102,6 +99,7 @@ module ModelContextProtocol
|
|
|
102
99
|
@enable_reaper = true
|
|
103
100
|
@reaper_interval = 60
|
|
104
101
|
@idle_timeout = 300
|
|
102
|
+
@ssl_params = nil
|
|
105
103
|
end
|
|
106
104
|
end
|
|
107
105
|
end
|