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.
@@ -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