vector_mcp 0.1.0 → 0.2.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/README.md +421 -114
- data/lib/vector_mcp/definitions.rb +210 -1
- data/lib/vector_mcp/errors.rb +39 -0
- data/lib/vector_mcp/handlers/core.rb +17 -0
- data/lib/vector_mcp/image_util.rb +358 -0
- data/lib/vector_mcp/sampling/request.rb +193 -0
- data/lib/vector_mcp/sampling/result.rb +80 -0
- data/lib/vector_mcp/server/capabilities.rb +156 -0
- data/lib/vector_mcp/server/message_handling.rb +166 -0
- data/lib/vector_mcp/server/registry.rb +289 -0
- data/lib/vector_mcp/server.rb +53 -415
- data/lib/vector_mcp/session.rb +100 -23
- data/lib/vector_mcp/transport/stdio.rb +174 -16
- data/lib/vector_mcp/util.rb +135 -10
- data/lib/vector_mcp/version.rb +1 -1
- data/lib/vector_mcp.rb +2 -1
- metadata +21 -1
@@ -0,0 +1,193 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../errors"
|
4
|
+
|
5
|
+
module VectorMCP
|
6
|
+
module Sampling
|
7
|
+
# Represents a sampling request to be sent to an MCP client.
|
8
|
+
# It validates the basic structure of the request.
|
9
|
+
class Request
|
10
|
+
attr_reader :messages, :model_preferences, :system_prompt,
|
11
|
+
:include_context, :temperature, :max_tokens,
|
12
|
+
:stop_sequences, :metadata
|
13
|
+
|
14
|
+
# Initializes a new Sampling::Request.
|
15
|
+
#
|
16
|
+
# @param params [Hash] The parameters for the sampling request.
|
17
|
+
# - :messages [Array<Hash>] (Required) Conversation history. Each message:
|
18
|
+
# - :role [String] (Required) "user" or "assistant".
|
19
|
+
# - :content [Hash] (Required) Message content.
|
20
|
+
# - :type [String] (Required) "text" or "image".
|
21
|
+
# - :text [String] (Optional) Text content if type is "text".
|
22
|
+
# - :data [String] (Optional) Base64 image data if type is "image".
|
23
|
+
# - :mime_type [String] (Optional) Mime type if type is "image".
|
24
|
+
# - :model_preferences [Hash] (Optional) Model selection preferences.
|
25
|
+
# - :system_prompt [String] (Optional) System prompt.
|
26
|
+
# - :include_context [String] (Optional) "none", "thisServer", "allServers".
|
27
|
+
# - :temperature [Float] (Optional) Sampling temperature.
|
28
|
+
# - :max_tokens [Integer] (Optional) Maximum tokens to generate.
|
29
|
+
# - :stop_sequences [Array<String>] (Optional) Stop sequences.
|
30
|
+
# - :metadata [Hash] (Optional) Provider-specific parameters.
|
31
|
+
# @raise [ArgumentError] if the basic structure is invalid.
|
32
|
+
def initialize(params = {})
|
33
|
+
params = params.transform_keys(&:to_sym) # Normalize keys
|
34
|
+
|
35
|
+
@messages = params[:messages]
|
36
|
+
@model_preferences = params[:model_preferences]
|
37
|
+
@system_prompt = params[:system_prompt]
|
38
|
+
@include_context = params[:include_context]
|
39
|
+
@temperature = params[:temperature]
|
40
|
+
@max_tokens = params[:max_tokens]
|
41
|
+
@stop_sequences = params[:stop_sequences]
|
42
|
+
@metadata = params[:metadata]
|
43
|
+
|
44
|
+
validate!
|
45
|
+
end
|
46
|
+
|
47
|
+
# Returns the request parameters as a hash, suitable for JSON serialization.
|
48
|
+
#
|
49
|
+
# @return [Hash]
|
50
|
+
def to_h
|
51
|
+
{
|
52
|
+
messages: @messages,
|
53
|
+
modelPreferences: @model_preferences, # MCP uses camelCase
|
54
|
+
systemPrompt: @system_prompt,
|
55
|
+
includeContext: @include_context,
|
56
|
+
temperature: @temperature,
|
57
|
+
maxTokens: @max_tokens,
|
58
|
+
stopSequences: @stop_sequences,
|
59
|
+
metadata: @metadata
|
60
|
+
}.compact # Remove nil values
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
def validate!
|
66
|
+
raise ArgumentError, "'messages' array is required" unless @messages.is_a?(Array) && !@messages.empty?
|
67
|
+
|
68
|
+
@messages.each_with_index do |msg, idx|
|
69
|
+
validate_message(msg, idx)
|
70
|
+
end
|
71
|
+
|
72
|
+
validate_optional_params
|
73
|
+
end
|
74
|
+
|
75
|
+
def validate_message(msg, idx)
|
76
|
+
raise ArgumentError, "Each message in 'messages' must be a Hash (at index #{idx})" unless msg.is_a?(Hash)
|
77
|
+
|
78
|
+
msg_role = extract_message_role(msg)
|
79
|
+
msg_content = extract_message_content(msg)
|
80
|
+
|
81
|
+
validate_message_role(msg_role, idx)
|
82
|
+
validate_message_content_structure(msg_content, idx)
|
83
|
+
validate_content_by_type(msg_content, idx)
|
84
|
+
end
|
85
|
+
|
86
|
+
def extract_message_role(msg)
|
87
|
+
msg[:role] || msg["role"]
|
88
|
+
end
|
89
|
+
|
90
|
+
def extract_message_content(msg)
|
91
|
+
msg[:content] || msg["content"]
|
92
|
+
end
|
93
|
+
|
94
|
+
def validate_message_role(role, idx)
|
95
|
+
raise ArgumentError, "Message role must be 'user' or 'assistant' (at index #{idx})" unless %w[user assistant].include?(role)
|
96
|
+
end
|
97
|
+
|
98
|
+
def validate_message_content_structure(content, idx)
|
99
|
+
raise ArgumentError, "Message content must be a Hash (at index #{idx})" unless content.is_a?(Hash)
|
100
|
+
end
|
101
|
+
|
102
|
+
def validate_content_by_type(content, idx)
|
103
|
+
content_type = content[:type] || content["type"]
|
104
|
+
raise ArgumentError, "Message content type must be 'text' or 'image' (at index #{idx})" unless %w[text image].include?(content_type)
|
105
|
+
|
106
|
+
case content_type
|
107
|
+
when "text"
|
108
|
+
validate_text_content(content, idx)
|
109
|
+
when "image"
|
110
|
+
validate_image_content(content, idx)
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
def validate_text_content(content, idx)
|
115
|
+
text_value = content[:text] || content["text"]
|
116
|
+
return unless text_value.to_s.empty?
|
117
|
+
|
118
|
+
raise ArgumentError, "Text content must not be empty if type is 'text' (at index #{idx})"
|
119
|
+
end
|
120
|
+
|
121
|
+
def validate_image_content(content, idx)
|
122
|
+
validate_image_data(content, idx)
|
123
|
+
validate_image_mime_type(content, idx)
|
124
|
+
end
|
125
|
+
|
126
|
+
def validate_image_data(content, idx)
|
127
|
+
data_value = content[:data] || content["data"]
|
128
|
+
return if data_value.is_a?(String) && !data_value.empty?
|
129
|
+
|
130
|
+
raise ArgumentError, "Image content 'data' (base64 string) is required if type is 'image' (at index #{idx})"
|
131
|
+
end
|
132
|
+
|
133
|
+
def validate_image_mime_type(content, idx)
|
134
|
+
mime_type_value = content[:mime_type] || content["mime_type"]
|
135
|
+
return if mime_type_value.is_a?(String) && !mime_type_value.empty?
|
136
|
+
|
137
|
+
raise ArgumentError, "Image content 'mime_type' is required if type is 'image' (at index #{idx})"
|
138
|
+
end
|
139
|
+
|
140
|
+
def validate_optional_params
|
141
|
+
validate_model_preferences
|
142
|
+
validate_system_prompt
|
143
|
+
validate_include_context
|
144
|
+
validate_temperature
|
145
|
+
validate_max_tokens
|
146
|
+
validate_stop_sequences
|
147
|
+
validate_metadata
|
148
|
+
end
|
149
|
+
|
150
|
+
def validate_model_preferences
|
151
|
+
return unless @model_preferences && !@model_preferences.is_a?(Hash)
|
152
|
+
|
153
|
+
raise ArgumentError, "'model_preferences' must be a Hash if provided"
|
154
|
+
end
|
155
|
+
|
156
|
+
def validate_system_prompt
|
157
|
+
return unless @system_prompt && !@system_prompt.is_a?(String)
|
158
|
+
|
159
|
+
raise ArgumentError, "'system_prompt' must be a String if provided"
|
160
|
+
end
|
161
|
+
|
162
|
+
def validate_include_context
|
163
|
+
return unless @include_context && !%w[none thisServer allServers].include?(@include_context)
|
164
|
+
|
165
|
+
raise ArgumentError, "'include_context' must be 'none', 'thisServer', or 'allServers' if provided"
|
166
|
+
end
|
167
|
+
|
168
|
+
def validate_temperature
|
169
|
+
return unless @temperature && !@temperature.is_a?(Numeric)
|
170
|
+
|
171
|
+
raise ArgumentError, "'temperature' must be a Numeric if provided"
|
172
|
+
end
|
173
|
+
|
174
|
+
def validate_max_tokens
|
175
|
+
return unless @max_tokens && !@max_tokens.is_a?(Integer)
|
176
|
+
|
177
|
+
raise ArgumentError, "'max_tokens' must be an Integer if provided"
|
178
|
+
end
|
179
|
+
|
180
|
+
def validate_stop_sequences
|
181
|
+
return unless @stop_sequences && !@stop_sequences.is_a?(Array)
|
182
|
+
|
183
|
+
raise ArgumentError, "'stop_sequences' must be an Array if provided"
|
184
|
+
end
|
185
|
+
|
186
|
+
def validate_metadata
|
187
|
+
return unless @metadata && !@metadata.is_a?(Hash)
|
188
|
+
|
189
|
+
raise ArgumentError, "'metadata' must be a Hash if provided"
|
190
|
+
end
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module VectorMCP
|
4
|
+
module Sampling
|
5
|
+
# Represents the result of a sampling request returned by an MCP client.
|
6
|
+
class Result
|
7
|
+
attr_reader :raw_result, :model, :stop_reason, :role, :content
|
8
|
+
|
9
|
+
# Initializes a new Sampling::Result.
|
10
|
+
#
|
11
|
+
# @param result_hash [Hash] The raw hash returned by the client for a sampling request.
|
12
|
+
# Expected keys (MCP spec uses camelCase, we symbolize and underscore internally):
|
13
|
+
# - 'model' [String] (Required) Name of the model used.
|
14
|
+
# - 'stopReason' [String] (Optional) Reason why generation stopped.
|
15
|
+
# - 'role' [String] (Required) "user" or "assistant".
|
16
|
+
# - 'content' [Hash] (Required) The generated content.
|
17
|
+
# - 'type' [String] (Required) "text" or "image".
|
18
|
+
# - 'text' [String] (Optional) Text content if type is "text".
|
19
|
+
# - 'data' [String] (Optional) Base64 image data if type is "image".
|
20
|
+
# - 'mimeType' [String] (Optional) Mime type if type is "image".
|
21
|
+
def initialize(result_hash)
|
22
|
+
@raw_result = result_hash.transform_keys { |k| k.to_s.gsub(/(.)([A-Z])/, '\1_\2').downcase.to_sym }
|
23
|
+
|
24
|
+
@model = @raw_result[:model]
|
25
|
+
@stop_reason = @raw_result[:stop_reason]
|
26
|
+
@role = @raw_result[:role]
|
27
|
+
@content = (@raw_result[:content] || {}).transform_keys(&:to_sym)
|
28
|
+
|
29
|
+
validate!
|
30
|
+
end
|
31
|
+
|
32
|
+
# @return [Boolean] True if the content type is 'text'.
|
33
|
+
def text?
|
34
|
+
@content[:type] == "text"
|
35
|
+
end
|
36
|
+
|
37
|
+
# @return [Boolean] True if the content type is 'image'.
|
38
|
+
def image?
|
39
|
+
@content[:type] == "image"
|
40
|
+
end
|
41
|
+
|
42
|
+
# @return [String, nil] The text content if type is 'text', otherwise nil.
|
43
|
+
def text_content
|
44
|
+
text? ? @content[:text] : nil
|
45
|
+
end
|
46
|
+
|
47
|
+
# @return [String, nil] The base64 encoded image data if type is 'image', otherwise nil.
|
48
|
+
def image_data
|
49
|
+
image? ? @content[:data] : nil
|
50
|
+
end
|
51
|
+
|
52
|
+
# @return [String, nil] The mime type of the image if type is 'image', otherwise nil.
|
53
|
+
def image_mime_type
|
54
|
+
image? ? @content[:mime_type] : nil
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
def validate!
|
60
|
+
raise ArgumentError, "'model' is required in sampling result" if @model.to_s.empty?
|
61
|
+
raise ArgumentError, "'role' is required in sampling result and must be 'user' or 'assistant'" unless %w[user assistant].include?(@role)
|
62
|
+
raise ArgumentError, "'content' hash is required in sampling result" if @content.empty?
|
63
|
+
|
64
|
+
content_type = @content[:type]
|
65
|
+
raise ArgumentError, "Content 'type' must be 'text' or 'image' in sampling result" unless %w[text image].include?(content_type)
|
66
|
+
|
67
|
+
if content_type == "text" && @content[:text].to_s.empty?
|
68
|
+
# NOTE: Some models might return empty text, so we don't raise an error here but allow nil from text_content
|
69
|
+
# raise ArgumentError, "Content 'text' must not be empty if type is 'text'"
|
70
|
+
end
|
71
|
+
|
72
|
+
return unless content_type == "image"
|
73
|
+
raise ArgumentError, "Content 'data' (base64 string) is required if type is 'image'" if @content[:data].to_s.empty?
|
74
|
+
return unless @content[:mime_type].to_s.empty?
|
75
|
+
|
76
|
+
raise ArgumentError, "Content 'mime_type' is required if type is 'image'"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,156 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module VectorMCP
|
4
|
+
class Server
|
5
|
+
# Handles server capabilities and configuration
|
6
|
+
module Capabilities
|
7
|
+
# --- Server Information and Capabilities ---
|
8
|
+
|
9
|
+
# Provides basic information about the server.
|
10
|
+
# @return [Hash] Server name and version.
|
11
|
+
def server_info
|
12
|
+
{ name: @name, version: @version }
|
13
|
+
end
|
14
|
+
|
15
|
+
# Returns the sampling configuration for this server.
|
16
|
+
# @return [Hash] The sampling configuration including capabilities and limits.
|
17
|
+
def sampling_config
|
18
|
+
@sampling_config[:config]
|
19
|
+
end
|
20
|
+
|
21
|
+
# Describes the capabilities of this server according to MCP specifications.
|
22
|
+
# @return [Hash] A capabilities object.
|
23
|
+
def server_capabilities
|
24
|
+
caps = {}
|
25
|
+
caps[:tools] = { listChanged: false } unless @tools.empty?
|
26
|
+
caps[:resources] = { subscribe: false, listChanged: false } unless @resources.empty?
|
27
|
+
caps[:prompts] = { listChanged: @prompts_list_changed } unless @prompts.empty?
|
28
|
+
caps[:roots] = { listChanged: true } unless @roots.empty?
|
29
|
+
caps[:sampling] = @sampling_config[:capabilities]
|
30
|
+
caps
|
31
|
+
end
|
32
|
+
|
33
|
+
# Resets the `prompts_list_changed` flag to false.
|
34
|
+
# @return [void]
|
35
|
+
def clear_prompts_list_changed
|
36
|
+
@prompts_list_changed = false
|
37
|
+
logger.debug("Prompts listChanged flag cleared.")
|
38
|
+
end
|
39
|
+
|
40
|
+
# Notifies connected clients that the list of available prompts has changed.
|
41
|
+
# @return [void]
|
42
|
+
def notify_prompts_list_changed
|
43
|
+
return unless transport && @prompts_list_changed
|
44
|
+
|
45
|
+
notification_method = "notifications/prompts/list_changed"
|
46
|
+
begin
|
47
|
+
if transport.respond_to?(:broadcast_notification)
|
48
|
+
logger.info("Broadcasting prompts list changed notification.")
|
49
|
+
transport.broadcast_notification(notification_method)
|
50
|
+
elsif transport.respond_to?(:send_notification)
|
51
|
+
logger.info("Sending prompts list changed notification (transport may broadcast or send to first client).")
|
52
|
+
transport.send_notification(notification_method)
|
53
|
+
else
|
54
|
+
logger.warn("Transport does not support sending notifications/prompts/list_changed.")
|
55
|
+
end
|
56
|
+
rescue StandardError => e
|
57
|
+
logger.error("Failed to send prompts list changed notification: #{e.class.name}: #{e.message}")
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
# Resets the `roots_list_changed` flag to false.
|
62
|
+
# @return [void]
|
63
|
+
def clear_roots_list_changed
|
64
|
+
@roots_list_changed = false
|
65
|
+
logger.debug("Roots listChanged flag cleared.")
|
66
|
+
end
|
67
|
+
|
68
|
+
# Notifies connected clients that the list of available roots has changed.
|
69
|
+
# @return [void]
|
70
|
+
def notify_roots_list_changed
|
71
|
+
return unless transport && @roots_list_changed
|
72
|
+
|
73
|
+
notification_method = "notifications/roots/list_changed"
|
74
|
+
begin
|
75
|
+
if transport.respond_to?(:broadcast_notification)
|
76
|
+
logger.info("Broadcasting roots list changed notification.")
|
77
|
+
transport.broadcast_notification(notification_method)
|
78
|
+
elsif transport.respond_to?(:send_notification)
|
79
|
+
logger.info("Sending roots list changed notification (transport may broadcast or send to first client).")
|
80
|
+
transport.send_notification(notification_method)
|
81
|
+
else
|
82
|
+
logger.warn("Transport does not support sending notifications/roots/list_changed.")
|
83
|
+
end
|
84
|
+
rescue StandardError => e
|
85
|
+
logger.error("Failed to send roots list changed notification: #{e.class.name}: #{e.message}")
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
# Registers a session as a subscriber to prompt list changes.
|
90
|
+
# @api private
|
91
|
+
def subscribe_prompts(session)
|
92
|
+
@prompt_subscribers << session unless @prompt_subscribers.include?(session)
|
93
|
+
logger.debug("Session subscribed to prompt list changes: #{session.object_id}")
|
94
|
+
end
|
95
|
+
|
96
|
+
private
|
97
|
+
|
98
|
+
# Configures sampling capabilities based on provided configuration.
|
99
|
+
# @api private
|
100
|
+
def configure_sampling_capabilities(config)
|
101
|
+
defaults = {
|
102
|
+
enabled: true,
|
103
|
+
methods: ["createMessage"],
|
104
|
+
supports_streaming: false,
|
105
|
+
supports_tool_calls: false,
|
106
|
+
supports_images: false,
|
107
|
+
max_tokens_limit: nil,
|
108
|
+
timeout_seconds: 30,
|
109
|
+
context_inclusion_methods: %w[none thisServer],
|
110
|
+
model_preferences_supported: true
|
111
|
+
}
|
112
|
+
|
113
|
+
resolved_config = defaults.merge(config.transform_keys(&:to_sym))
|
114
|
+
capabilities = build_sampling_capabilities_object(resolved_config)
|
115
|
+
|
116
|
+
{
|
117
|
+
config: resolved_config,
|
118
|
+
capabilities: capabilities
|
119
|
+
}
|
120
|
+
end
|
121
|
+
|
122
|
+
# Builds the complete sampling capabilities object.
|
123
|
+
# @api private
|
124
|
+
def build_sampling_capabilities_object(config)
|
125
|
+
return {} unless config[:enabled]
|
126
|
+
|
127
|
+
{
|
128
|
+
methods: config[:methods],
|
129
|
+
features: build_sampling_features(config),
|
130
|
+
limits: build_sampling_limits(config),
|
131
|
+
contextInclusion: config[:context_inclusion_methods]
|
132
|
+
}
|
133
|
+
end
|
134
|
+
|
135
|
+
# Builds the features section of sampling capabilities.
|
136
|
+
# @api private
|
137
|
+
def build_sampling_features(config)
|
138
|
+
features = {}
|
139
|
+
features[:streaming] = true if config[:supports_streaming]
|
140
|
+
features[:toolCalls] = true if config[:supports_tool_calls]
|
141
|
+
features[:images] = true if config[:supports_images]
|
142
|
+
features[:modelPreferences] = true if config[:model_preferences_supported]
|
143
|
+
features
|
144
|
+
end
|
145
|
+
|
146
|
+
# Builds the limits section of sampling capabilities.
|
147
|
+
# @api private
|
148
|
+
def build_sampling_limits(config)
|
149
|
+
limits = {}
|
150
|
+
limits[:maxTokens] = config[:max_tokens_limit] if config[:max_tokens_limit]
|
151
|
+
limits[:defaultTimeout] = config[:timeout_seconds] if config[:timeout_seconds]
|
152
|
+
limits
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
@@ -0,0 +1,166 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module VectorMCP
|
4
|
+
class Server
|
5
|
+
# Handles message processing and request/notification dispatching
|
6
|
+
module MessageHandling
|
7
|
+
# --- Message Handling Logic (primarily called by transports) ---
|
8
|
+
|
9
|
+
# Handles an incoming JSON-RPC message (request or notification).
|
10
|
+
# This is the main dispatch point for messages received by a transport.
|
11
|
+
#
|
12
|
+
# @param message [Hash] The parsed JSON-RPC message object.
|
13
|
+
# @param session [VectorMCP::Session] The client session associated with this message.
|
14
|
+
# @param session_id [String] A unique identifier for the underlying transport connection.
|
15
|
+
# @return [Object, nil] For requests, returns the result data to be sent in the JSON-RPC response.
|
16
|
+
# For notifications, returns `nil`.
|
17
|
+
# @raise [VectorMCP::ProtocolError] if the message is invalid or an error occurs during handling.
|
18
|
+
def handle_message(message, session, session_id)
|
19
|
+
id = message["id"]
|
20
|
+
method = message["method"]
|
21
|
+
params = message["params"] || {}
|
22
|
+
|
23
|
+
if id && method # Request
|
24
|
+
logger.info("[#{session_id}] Request [#{id}]: #{method} with params: #{params.inspect}")
|
25
|
+
handle_request(id, method, params, session)
|
26
|
+
elsif method # Notification
|
27
|
+
logger.info("[#{session_id}] Notification: #{method} with params: #{params.inspect}")
|
28
|
+
handle_notification(method, params, session)
|
29
|
+
nil # Notifications do not have a return value to send back to client
|
30
|
+
elsif id # Invalid: Has ID but no method
|
31
|
+
logger.warn("[#{session_id}] Invalid message: Has ID [#{id}] but no method. #{message.inspect}")
|
32
|
+
raise VectorMCP::InvalidRequestError.new("Request object must include a 'method' member.", request_id: id)
|
33
|
+
else # Invalid: No ID and no method
|
34
|
+
logger.warn("[#{session_id}] Invalid message: Missing both 'id' and 'method'. #{message.inspect}")
|
35
|
+
raise VectorMCP::InvalidRequestError.new("Invalid message format", request_id: nil)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# --- Request/Notification Hook Methods ---
|
40
|
+
|
41
|
+
# Registers a handler for a specific JSON-RPC request method.
|
42
|
+
#
|
43
|
+
# @param method [String, Symbol] The method name (e.g., "my/customMethod").
|
44
|
+
# @yield [params, session, server] A block to handle the request.
|
45
|
+
# @return [self] The server instance.
|
46
|
+
def on_request(method, &handler)
|
47
|
+
@request_handlers[method.to_s] = handler
|
48
|
+
self
|
49
|
+
end
|
50
|
+
|
51
|
+
# Registers a handler for a specific JSON-RPC notification method.
|
52
|
+
#
|
53
|
+
# @param method [String, Symbol] The method name (e.g., "my/customNotification").
|
54
|
+
# @yield [params, session, server] A block to handle the notification.
|
55
|
+
# @return [self] The server instance.
|
56
|
+
def on_notification(method, &handler)
|
57
|
+
@notification_handlers[method.to_s] = handler
|
58
|
+
self
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
# Internal handler for JSON-RPC requests.
|
64
|
+
# @api private
|
65
|
+
def handle_request(id, method, params, session)
|
66
|
+
validate_session_initialization(id, method, params, session)
|
67
|
+
|
68
|
+
handler = @request_handlers[method]
|
69
|
+
raise VectorMCP::MethodNotFoundError.new(method, request_id: id) unless handler
|
70
|
+
|
71
|
+
execute_request_handler(id, method, params, session, handler)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Validates that the session is properly initialized for the given request.
|
75
|
+
# @api private
|
76
|
+
def validate_session_initialization(id, method, _params, session)
|
77
|
+
return if session.initialized?
|
78
|
+
|
79
|
+
# Allow "initialize" even if not marked initialized yet by server
|
80
|
+
return if method == "initialize"
|
81
|
+
|
82
|
+
# For any other method, session must be initialized
|
83
|
+
raise VectorMCP::InitializationError.new("Session not initialized. Client must send 'initialize' first.", request_id: id)
|
84
|
+
end
|
85
|
+
|
86
|
+
# Executes the request handler with proper error handling and tracking.
|
87
|
+
# @api private
|
88
|
+
def execute_request_handler(id, method, params, session, handler)
|
89
|
+
@in_flight_requests[id] = { method: method, params: params, session: session, start_time: Time.now }
|
90
|
+
result = handler.call(params, session, self)
|
91
|
+
result
|
92
|
+
rescue VectorMCP::ProtocolError => e
|
93
|
+
# Ensure the request ID from the current context is on the error
|
94
|
+
e.request_id = id unless e.request_id && e.request_id == id
|
95
|
+
raise e # Re-raise with potentially updated request_id
|
96
|
+
rescue StandardError => e
|
97
|
+
handle_request_error(id, method, e)
|
98
|
+
ensure
|
99
|
+
@in_flight_requests.delete(id)
|
100
|
+
end
|
101
|
+
|
102
|
+
# Handles unexpected errors during request processing.
|
103
|
+
# @api private
|
104
|
+
def handle_request_error(id, method, error)
|
105
|
+
logger.error("Unhandled error during request '#{method}' (ID: #{id}): #{error.message}\nBacktrace: #{error.backtrace.join("\n ")}")
|
106
|
+
raise VectorMCP::InternalError.new(
|
107
|
+
"Request handler failed unexpectedly",
|
108
|
+
request_id: id,
|
109
|
+
details: { method: method, error: "An internal error occurred" }
|
110
|
+
)
|
111
|
+
end
|
112
|
+
|
113
|
+
# Internal handler for JSON-RPC notifications.
|
114
|
+
# @api private
|
115
|
+
def handle_notification(method, params, session)
|
116
|
+
unless session.initialized? || method == "initialized"
|
117
|
+
logger.warn("Ignoring notification '#{method}' before session is initialized. Params: #{params.inspect}")
|
118
|
+
return
|
119
|
+
end
|
120
|
+
|
121
|
+
handler = @notification_handlers[method]
|
122
|
+
if handler
|
123
|
+
begin
|
124
|
+
handler.call(params, session, self)
|
125
|
+
rescue StandardError => e
|
126
|
+
logger.error("Error executing notification handler '#{method}': #{e.message}\nBacktrace (top 5):\n #{e.backtrace.first(5).join("\n ")}")
|
127
|
+
# Notifications must not generate a response, even on error.
|
128
|
+
end
|
129
|
+
else
|
130
|
+
logger.debug("No handler registered for notification: #{method}")
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
# Sets up default handlers for core MCP methods using {VectorMCP::Handlers::Core}.
|
135
|
+
# @api private
|
136
|
+
def setup_default_handlers
|
137
|
+
# Core Requests
|
138
|
+
on_request("initialize", &session_method(:initialize!))
|
139
|
+
on_request("ping", &Handlers::Core.method(:ping))
|
140
|
+
on_request("tools/list", &Handlers::Core.method(:list_tools))
|
141
|
+
on_request("tools/call", &Handlers::Core.method(:call_tool))
|
142
|
+
on_request("resources/list", &Handlers::Core.method(:list_resources))
|
143
|
+
on_request("resources/read", &Handlers::Core.method(:read_resource))
|
144
|
+
on_request("prompts/list", &Handlers::Core.method(:list_prompts))
|
145
|
+
on_request("prompts/get", &Handlers::Core.method(:get_prompt))
|
146
|
+
on_request("prompts/subscribe", &Handlers::Core.method(:subscribe_prompts))
|
147
|
+
on_request("roots/list", &Handlers::Core.method(:list_roots))
|
148
|
+
|
149
|
+
# Core Notifications
|
150
|
+
on_notification("initialized", &Handlers::Core.method(:initialized_notification))
|
151
|
+
# Standard cancel request names
|
152
|
+
%w[$/cancelRequest $/cancel notifications/cancelled].each do |cancel_method|
|
153
|
+
on_notification(cancel_method, &Handlers::Core.method(:cancel_request_notification))
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
# Helper to create a proc that calls a method on the session object.
|
158
|
+
# @api private
|
159
|
+
def session_method(method_name)
|
160
|
+
lambda do |params, session, _server|
|
161
|
+
session.public_send(method_name, params)
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|