vector_mcp 0.1.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,521 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "logger"
5
+ require_relative "definitions"
6
+ require_relative "session"
7
+ require_relative "errors"
8
+ require_relative "transport/stdio" # Default transport
9
+ require_relative "transport/sse"
10
+ require_relative "handlers/core" # Default handlers
11
+ require_relative "util" # Needed if not using Handlers::Core
12
+
13
+ module VectorMCP
14
+ # The `Server` class is the central component for an MCP server implementation.
15
+ # It manages tools, resources, prompts, and handles the MCP message lifecycle.
16
+ #
17
+ # A server instance is typically initialized, configured with capabilities (tools,
18
+ # resources, prompts), and then run with a chosen transport mechanism (e.g., Stdio, SSE).
19
+ #
20
+ # @example Creating and running a simple server
21
+ # server = VectorMCP::Server.new(name: "MySimpleServer", version: "1.0")
22
+ #
23
+ # server.register_tool(
24
+ # name: "echo",
25
+ # description: "Echoes back the input string.",
26
+ # input_schema: { type: "object", properties: { message: { type: "string" } } }
27
+ # ) do |args|
28
+ # args["message"]
29
+ # end
30
+ #
31
+ # server.run(transport: :stdio) # Runs with Stdio transport by default
32
+ #
33
+ # @!attribute [r] logger
34
+ # @return [Logger] The logger instance for this server.
35
+ # @!attribute [r] name
36
+ # @return [String] The name of the server.
37
+ # @!attribute [r] version
38
+ # @return [String] The version of the server software.
39
+ # @!attribute [r] protocol_version
40
+ # @return [String] The MCP protocol version this server implements.
41
+ # @!attribute [r] tools
42
+ # @return [Hash<String, VectorMCP::Definitions::Tool>] Registered tools, keyed by name.
43
+ # @!attribute [r] resources
44
+ # @return [Hash<String, VectorMCP::Definitions::Resource>] Registered resources, keyed by URI string.
45
+ # @!attribute [r] prompts
46
+ # @return [Hash<String, VectorMCP::Definitions::Prompt>] Registered prompts, keyed by name.
47
+ # @!attribute [r] in_flight_requests
48
+ # @return [Hash] A hash tracking currently processing requests, for cancellation purposes.
49
+ # @!attribute [rw] transport
50
+ # @return [VectorMCP::Transport::Base, nil] The active transport instance, if any.
51
+ class Server
52
+ include Definitions # Make Tool, Resource, Prompt structs easily available
53
+
54
+ # The specific version of the Model Context Protocol this server implements.
55
+ PROTOCOL_VERSION = "2024-11-05"
56
+
57
+ attr_reader :logger, :name, :version, :protocol_version, :tools, :resources, :prompts, :in_flight_requests
58
+ attr_accessor :transport
59
+
60
+ # Initializes a new MCP Server.
61
+ #
62
+ # @param name_pos [String, nil] Positional argument for the server name.
63
+ # Superseded by `name:` keyword if both provided and different.
64
+ # @param name [String] The name of the server (required).
65
+ # @param version [String] The version of this server application (default: "0.1.0").
66
+ # @param log_level [Integer] The logger level (e.g., `Logger::INFO`, `Logger::DEBUG`).
67
+ # Defaults to `Logger::INFO` via the shared `VectorMCP.logger`.
68
+ # @param protocol_version [String] The MCP protocol version string this server uses
69
+ # (default: {PROTOCOL_VERSION}).
70
+ # @raise [ArgumentError] if name is not provided or is empty.
71
+ # @raise [ArgumentError] if `name_pos` and `name:` are both provided but differ.
72
+ # rubocop:disable Metrics/ParameterLists
73
+ def initialize(name_pos = nil, *, name: nil, version: "0.1.0", log_level: Logger::INFO, protocol_version: PROTOCOL_VERSION)
74
+ if name_pos && name && name_pos != name
75
+ raise ArgumentError, "Specify the server name either positionally or with the `name:` keyword (not both)."
76
+ end
77
+
78
+ @name = name_pos || name
79
+ raise ArgumentError, "Server name is required" if @name.nil? || @name.to_s.strip.empty?
80
+
81
+ @version = version
82
+ @protocol_version = protocol_version
83
+ @logger = VectorMCP.logger
84
+ @logger.level = log_level
85
+
86
+ @request_handlers = {}
87
+ @notification_handlers = {}
88
+ @tools = {}
89
+ @resources = {}
90
+ @prompts = {}
91
+ @in_flight_requests = {} # For $/cancelRequest
92
+ @prompts_list_changed = false
93
+ @prompt_subscribers = [] # For `notifications/prompts/subscribe`
94
+
95
+ setup_default_handlers
96
+ logger.info("Server instance '#{@name}' v#{@version} (MCP Protocol: #{@protocol_version}, Gem: v#{VectorMCP::VERSION}) initialized.")
97
+ end
98
+ # rubocop:enable Metrics/ParameterLists
99
+
100
+ # --- Registration Methods ---
101
+
102
+ # Registers a new tool with the server.
103
+ #
104
+ # @param name [String, Symbol] The unique name for the tool.
105
+ # @param description [String] A human-readable description of the tool.
106
+ # @param input_schema [Hash] A JSON Schema object that precisely describes the
107
+ # structure of the argument hash your tool expects. The schema **must** be
108
+ # compatible with the official MCP JSON-Schema draft so that remote
109
+ # validators can verify user input.
110
+ # @yield [Hash] A block implementing the tool logic. The yielded argument is
111
+ # the user-supplied input hash, already guaranteed (by the caller) to match
112
+ # `input_schema`.
113
+ # @return [self] Returns the server instance so you can chain
114
+ # registrations—e.g., `server.register_tool(...).register_resource(...)`.
115
+ # @raise [ArgumentError] If another tool with the same name is already registered.
116
+ def register_tool(name:, description:, input_schema:, &handler)
117
+ name_s = name.to_s
118
+ raise ArgumentError, "Tool '#{name_s}' already registered" if @tools[name_s]
119
+
120
+ @tools[name_s] = Tool.new(name_s, description, input_schema, handler)
121
+ logger.debug("Registered tool: #{name_s}")
122
+ self
123
+ end
124
+
125
+ # Registers a new resource with the server.
126
+ #
127
+ # @param uri [String, URI] The unique URI for the resource.
128
+ # @param name [String] A human-readable name for the resource.
129
+ # @param description [String] A description of the resource.
130
+ # @param mime_type [String] The MIME type of the resource's content (default: "text/plain").
131
+ # @yield [Hash] A block that provides the resource's content. It may receive parameters
132
+ # from the `resources/read` request (for dynamic resources; for static resources, parameters may be ignored).
133
+ # The block should return **any Ruby value**; the value will be normalised
134
+ # to MCP `Content[]` via {VectorMCP::Util.convert_to_mcp_content}.
135
+ # @return [self] The server instance, for chaining.
136
+ # @raise [ArgumentError] if a resource with the same URI is already registered.
137
+ def register_resource(uri:, name:, description:, mime_type: "text/plain", &handler)
138
+ uri_s = uri.to_s
139
+ raise ArgumentError, "Resource '#{uri_s}' already registered" if @resources[uri_s]
140
+
141
+ @resources[uri_s] = Resource.new(uri, name, description, mime_type, handler)
142
+ logger.debug("Registered resource: #{uri_s}")
143
+ self
144
+ end
145
+
146
+ # Registers a new prompt with the server.
147
+ #
148
+ # @param name [String, Symbol] The unique name for the prompt.
149
+ # @param description [String] A human-readable description of the prompt.
150
+ # @param arguments [Array<Hash>] An array defining the prompt's arguments.
151
+ # Each hash should conform to the prompt argument schema (e.g., `{ name:, description:, required: }`).
152
+ # @yield [Hash] A block that generates the prompt. It receives a hash of arguments,
153
+ # validated against the prompt's argument definitions. The block must
154
+ # return a hash conforming to the MCP *GetPromptResult* schema—see
155
+ # Handlers::Core#get_prompt for the exact contract enforced.
156
+ # @return [self] The server instance, for chaining.
157
+ # @raise [ArgumentError] if a prompt with the same name is already registered, or if
158
+ # the `arguments` definition is invalid.
159
+ def register_prompt(name:, description:, arguments: [], &handler)
160
+ name_s = name.to_s
161
+ raise ArgumentError, "Prompt '#{name_s}' already registered" if @prompts[name_s]
162
+
163
+ validate_prompt_arguments(arguments)
164
+ @prompts[name_s] = Prompt.new(name_s, description, arguments, handler)
165
+ @prompts_list_changed = true
166
+ notify_prompts_list_changed
167
+ logger.debug("Registered prompt: #{name_s}")
168
+ self
169
+ end
170
+
171
+ # --- Request/Notification Hook Methods ---
172
+
173
+ # Registers a handler for a specific JSON-RPC request method.
174
+ #
175
+ # @param method [String, Symbol] The method name (e.g., "my/customMethod").
176
+ # @yield [params, session, server] A block to handle the request.
177
+ # - `params` [Hash]: The request parameters.
178
+ # - `session` [VectorMCP::Session]: The current client session.
179
+ # - `server` [VectorMCP::Server]: The server instance itself.
180
+ # The block should return the result for the JSON-RPC response.
181
+ # @return [self] The server instance.
182
+ def on_request(method, &handler)
183
+ @request_handlers[method.to_s] = handler
184
+ self
185
+ end
186
+
187
+ # Registers a handler for a specific JSON-RPC notification method.
188
+ #
189
+ # @param method [String, Symbol] The method name (e.g., "my/customNotification").
190
+ # @yield [params, session, server] A block to handle the notification.
191
+ # (Parameters are the same as for `on_request`.)
192
+ # @return [self] The server instance.
193
+ def on_notification(method, &handler)
194
+ @notification_handlers[method.to_s] = handler
195
+ self
196
+ end
197
+
198
+ # --- Server Execution ---
199
+
200
+ # Runs the server using the specified transport mechanism.
201
+ #
202
+ # @param transport [:stdio, :sse, VectorMCP::Transport::Base] The transport to use.
203
+ # Can be a symbol (`:stdio`, `:sse`) or an initialized transport instance.
204
+ # If a symbol is provided, the method will instantiate the corresponding transport class.
205
+ # If `:sse` is chosen, ensure `async` and `falcon` gems are available.
206
+ # @param options [Hash] Transport-specific options (e.g., `:host`, `:port` for SSE).
207
+ # These are passed to the transport's constructor if a symbol is provided for `transport`.
208
+ # @return [void]
209
+ # @raise [ArgumentError] if an unsupported transport symbol is given.
210
+ # @raise [NotImplementedError] if `:sse` transport is specified (currently a placeholder).
211
+ def run(transport: :stdio, **options)
212
+ active_transport = case transport
213
+ when :stdio
214
+ VectorMCP::Transport::Stdio.new(self, **options)
215
+ when :sse
216
+ # VectorMCP::Transport::SSE.new(self, **options)
217
+ raise NotImplementedError, "SSE transport is not yet supported"
218
+ when VectorMCP::Transport::Base # Allow passing an initialized transport instance
219
+ transport.server = self if transport.respond_to?(:server=) && transport.server.nil? # Ensure server is set
220
+ transport
221
+ else
222
+ logger.fatal("Unsupported transport type: #{transport.inspect}")
223
+ raise ArgumentError, "Unsupported transport: #{transport.inspect}"
224
+ end
225
+ self.transport = active_transport
226
+ active_transport.run
227
+ end
228
+
229
+ # --- Message Handling Logic (primarily called by transports) ---
230
+
231
+ # Handles an incoming JSON-RPC message (request or notification).
232
+ # This is the main dispatch point for messages received by a transport.
233
+ #
234
+ # @param message [Hash] The parsed JSON-RPC message object.
235
+ # @param session [VectorMCP::Session] The client session associated with this message.
236
+ # @param session_id [String] A unique identifier for the underlying transport connection (e.g., socket ID, stdio pipe).
237
+ # @return [Object, nil] For requests, returns the result data to be sent in the JSON-RPC response.
238
+ # For notifications, returns `nil`.
239
+ # @raise [VectorMCP::ProtocolError] if the message is invalid or an error occurs during handling
240
+ # that should be reported as a JSON-RPC error. May also raise subclasses such as NotFoundError, InvalidParamsError, etc.
241
+ def handle_message(message, session, session_id)
242
+ id = message["id"]
243
+ method = message["method"]
244
+ params = message["params"] || {} # Default to empty hash if params is nil
245
+
246
+ if id && method # Request
247
+ logger.info("[#{session_id}] Request [#{id}]: #{method} with params: #{params.inspect}")
248
+ handle_request(id, method, params, session)
249
+ elsif method # Notification
250
+ logger.info("[#{session_id}] Notification: #{method} with params: #{params.inspect}")
251
+ handle_notification(method, params, session)
252
+ nil # Notifications do not have a return value to send back to client
253
+ elsif id # Invalid: Has ID but no method (likely a malformed request or client sending a response)
254
+ logger.warn("[#{session_id}] Invalid message: Has ID [#{id}] but no method. #{message.inspect}")
255
+ raise VectorMCP::InvalidRequestError.new("Request object must include a 'method' member.", request_id: id)
256
+ else # Invalid: No ID and no method
257
+ logger.warn("[#{session_id}] Invalid message: Missing both 'id' and 'method'. #{message.inspect}")
258
+ raise VectorMCP::InvalidRequestError.new("Invalid message format", request_id: nil)
259
+ end
260
+ end
261
+
262
+ # --- Server Information and Capabilities ---
263
+
264
+ # Provides basic information about the server.
265
+ # @return [Hash] Server name and version.
266
+ def server_info
267
+ { name: @name, version: @version }
268
+ end
269
+
270
+ # Describes the capabilities of this server according to MCP specifications.
271
+ # @return [Hash] A capabilities object.
272
+ def server_capabilities
273
+ caps = {}
274
+ caps[:tools] = { listChanged: false } unless @tools.empty? # `listChanged` for tools is not standard but included for symmetry
275
+ caps[:resources] = { subscribe: false, listChanged: false } unless @resources.empty?
276
+ caps[:prompts] = { listChanged: @prompts_list_changed } unless @prompts.empty?
277
+ # `experimental` is a defined field in MCP capabilities, can be used for non-standard features.
278
+ caps[:experimental] = {}
279
+ caps
280
+ end
281
+
282
+ # Resets the `prompts_list_changed` flag to false.
283
+ # Typically called by the `prompts/list` handler after a client has fetched the updated list.
284
+ # @return [void]
285
+ def clear_prompts_list_changed
286
+ @prompts_list_changed = false
287
+ logger.debug("Prompts listChanged flag cleared.")
288
+ end
289
+
290
+ # Notifies connected clients that the list of available prompts has changed.
291
+ # This method attempts to use `broadcast_notification` if the transport supports it,
292
+ # otherwise falls back to `send_notification` (which might only make sense for single-client transports like stdio).
293
+ # @return [void]
294
+ def notify_prompts_list_changed
295
+ return unless transport && @prompts_list_changed # Only notify if there was a change and transport is up
296
+
297
+ notification_method = "notifications/prompts/list_changed"
298
+ begin
299
+ if transport.respond_to?(:broadcast_notification)
300
+ logger.info("Broadcasting prompts list changed notification.")
301
+ transport.broadcast_notification(notification_method)
302
+ elsif transport.respond_to?(:send_notification)
303
+ # For single-client transports or as a fallback if broadcast isn't specific
304
+ logger.info("Sending prompts list changed notification (transport may broadcast or send to first client).")
305
+ transport.send_notification(notification_method) # Transport needs to decide target if not broadcast
306
+ else
307
+ logger.warn("Transport does not support sending notifications/prompts/list_changed.")
308
+ end
309
+ rescue StandardError => e
310
+ logger.error("Failed to send prompts list changed notification: #{e.class.name}: #{e.message}")
311
+ end
312
+ end
313
+
314
+ private
315
+
316
+ # Registers a session as a subscriber to prompt list changes.
317
+ # Used by the `prompts/subscribe` handler.
318
+ # @api private
319
+ # @param session [VectorMCP::Session] The session to subscribe.
320
+ # @return [void]
321
+ def subscribe_prompts(session)
322
+ @prompt_subscribers << session unless @prompt_subscribers.include?(session)
323
+ logger.debug("Session subscribed to prompt list changes: #{session.object_id}")
324
+ end
325
+
326
+ # Internal handler for JSON-RPC requests.
327
+ # @api private
328
+ # @param id [String, Integer] The request ID.
329
+ # @param method [String] The request method name.
330
+ # @param params [Hash] The request parameters.
331
+ # @param session [VectorMCP::Session] The client session.
332
+ # @return [Object] The result of the request handler.
333
+ # @raise [VectorMCP::ProtocolError] Propagates errors from handlers or raises new ones (e.g., MethodNotFound, NotFoundError, etc.).
334
+ # rubocop:disable Metrics/MethodLength
335
+ def handle_request(id, method, params, session)
336
+ unless session.initialized?
337
+ # Allow "initialize" even if not marked initialized yet by server
338
+ return session.initialize!(params) if method == "initialize"
339
+
340
+ # For any other method, session must be initialized
341
+ raise VectorMCP::InitializationError.new("Session not initialized. Client must send 'initialize' first.", request_id: id)
342
+ end
343
+
344
+ handler = @request_handlers[method]
345
+ raise VectorMCP::MethodNotFoundError.new(method, request_id: id) unless handler
346
+
347
+ begin
348
+ @in_flight_requests[id] = { method: method, params: params, session: session, start_time: Time.now }
349
+ result = handler.call(params, session, self)
350
+ result
351
+ rescue VectorMCP::ProtocolError => e # Includes NotFoundError, InvalidParamsError, InternalError from handlers
352
+ # Ensure the request ID from the current context is on the error
353
+ e.request_id = id unless e.request_id && e.request_id == id
354
+ raise e # Re-raise with potentially updated request_id
355
+ rescue StandardError => e
356
+ logger.error("Unhandled error during request '#{method}' (ID: #{id}): #{e.message}\nBacktrace: #{e.backtrace.join("\n ")}")
357
+ raise VectorMCP::InternalError.new(
358
+ "Request handler failed unexpectedly",
359
+ request_id: id,
360
+ details: { method: method, error: "An internal error occurred" }
361
+ )
362
+ ensure
363
+ @in_flight_requests.delete(id)
364
+ end
365
+ end
366
+ # rubocop:enable Metrics/MethodLength
367
+
368
+ # Internal handler for JSON-RPC notifications.
369
+ # @api private
370
+ # @param method [String] The notification method name.
371
+ # @param params [Hash] The notification parameters.
372
+ # @param session [VectorMCP::Session] The client session.
373
+ # @return [void]
374
+ # @raise [StandardError] if the notification handler raises an error (errors are logged, not propagated to the client).
375
+ def handle_notification(method, params, session)
376
+ unless session.initialized? || method == "initialized"
377
+ logger.warn("Ignoring notification '#{method}' before session is initialized. Params: #{params.inspect}")
378
+ return
379
+ end
380
+
381
+ handler = @notification_handlers[method]
382
+ if handler
383
+ begin
384
+ handler.call(params, session, self)
385
+ rescue StandardError => e
386
+ logger.error("Error executing notification handler '#{method}': #{e.message}\nBacktrace (top 5):\n #{e.backtrace.first(5).join("\n ")}")
387
+ # Notifications must not generate a response, even on error.
388
+ end
389
+ else
390
+ logger.debug("No handler registered for notification: #{method}")
391
+ end
392
+ end
393
+
394
+ # Sets up default handlers for core MCP methods using {VectorMCP::Handlers::Core}.
395
+ # @api private
396
+ # @return [void]
397
+ def setup_default_handlers
398
+ # Core Requests
399
+ on_request("initialize", &session_method(:initialize!))
400
+ on_request("ping", &Handlers::Core.method(:ping))
401
+ on_request("tools/list", &Handlers::Core.method(:list_tools))
402
+ on_request("tools/call", &Handlers::Core.method(:call_tool))
403
+ on_request("resources/list", &Handlers::Core.method(:list_resources))
404
+ on_request("resources/read", &Handlers::Core.method(:read_resource))
405
+ on_request("prompts/list", &Handlers::Core.method(:list_prompts))
406
+ on_request("prompts/get", &Handlers::Core.method(:get_prompt))
407
+ on_request("prompts/subscribe", &Handlers::Core.method(:subscribe_prompts))
408
+
409
+ # Core Notifications
410
+ on_notification("initialized", &Handlers::Core.method(:initialized_notification))
411
+ # Standard cancel request names
412
+ %w[$/cancelRequest $/cancel notifications/cancelled].each do |cancel_method|
413
+ on_notification(cancel_method, &Handlers::Core.method(:cancel_request_notification))
414
+ end
415
+ end
416
+
417
+ # Helper to create a proc that calls a method on the session object.
418
+ # Used for the `initialize` request handler.
419
+ # @api private
420
+ def session_method(method_name)
421
+ lambda do |params, session, _server|
422
+ session.public_send(method_name, params)
423
+ end
424
+ end
425
+
426
+ # Validates the structure of the `arguments` array provided to {#register_prompt}.
427
+ # Each item must be a Hash with at least a `:name`, and optionally `:description` and `:required`.
428
+ # @api private
429
+ # @param argument_defs [Array<Hash>] The array of argument definitions to validate.
430
+ # @return [void]
431
+ # @raise [ArgumentError] if `argument_defs` is not an Array or if any definition is invalid.
432
+ def validate_prompt_arguments(argument_defs)
433
+ raise ArgumentError, "Prompt arguments definition must be an Array of Hashes." unless argument_defs.is_a?(Array)
434
+
435
+ argument_defs.each_with_index { |arg, idx| validate_single_prompt_argument(arg, idx) }
436
+ end
437
+
438
+ # Defines the keys allowed in a prompt argument definition hash.
439
+ ALLOWED_PROMPT_ARG_KEYS = %w[name description required type].freeze # Added 'type' as per common usage
440
+ private_constant :ALLOWED_PROMPT_ARG_KEYS
441
+
442
+ # Validates a single prompt argument definition hash.
443
+ # @api private
444
+ # @param arg [Hash] The argument definition hash.
445
+ # @param idx [Integer] The index of this argument in the list (for error messages).
446
+ # @return [void]
447
+ # @raise [ArgumentError] if the argument definition is invalid.
448
+ def validate_single_prompt_argument(arg, idx)
449
+ raise ArgumentError, "Prompt argument definition at index #{idx} must be a Hash. Found: #{arg.class}" unless arg.is_a?(Hash)
450
+
451
+ validate_prompt_arg_name!(arg, idx)
452
+ validate_prompt_arg_description!(arg, idx)
453
+ validate_prompt_arg_required_flag!(arg, idx)
454
+ validate_prompt_arg_type!(arg, idx) # New validation for :type
455
+ validate_prompt_arg_unknown_keys!(arg, idx)
456
+ end
457
+
458
+ # Validates the :name key of a prompt argument definition.
459
+ # @api private
460
+ def validate_prompt_arg_name!(arg, idx)
461
+ name_val = arg[:name] || arg["name"]
462
+ raise ArgumentError, "Prompt argument at index #{idx} missing :name" if name_val.nil?
463
+ unless name_val.is_a?(String) || name_val.is_a?(Symbol)
464
+ raise ArgumentError, "Prompt argument :name at index #{idx} must be a String or Symbol. Found: #{name_val.class}"
465
+ end
466
+ raise ArgumentError, "Prompt argument :name at index #{idx} cannot be empty." if name_val.to_s.strip.empty?
467
+ end
468
+
469
+ # Validates the :description key of a prompt argument definition.
470
+ # @api private
471
+ def validate_prompt_arg_description!(arg, idx)
472
+ return unless arg.key?(:description) || arg.key?("description") # Optional field
473
+
474
+ desc_val = arg[:description] || arg["description"]
475
+ return if desc_val.nil? || desc_val.is_a?(String) # Allow nil or String
476
+
477
+ raise ArgumentError, "Prompt argument :description at index #{idx} must be a String if provided. Found: #{desc_val.class}"
478
+ end
479
+
480
+ # Validates the :required key of a prompt argument definition.
481
+ # @api private
482
+ def validate_prompt_arg_required_flag!(arg, idx)
483
+ return unless arg.key?(:required) || arg.key?("required") # Optional field
484
+
485
+ req_val = arg[:required] || arg["required"]
486
+ return if [true, false].include?(req_val)
487
+
488
+ raise ArgumentError, "Prompt argument :required at index #{idx} must be true or false if provided. Found: #{req_val.inspect}"
489
+ end
490
+
491
+ # Validates the :type key of a prompt argument definition (new).
492
+ # @api private
493
+ def validate_prompt_arg_type!(arg, idx)
494
+ return unless arg.key?(:type) || arg.key?("type") # Optional field
495
+
496
+ type_val = arg[:type] || arg["type"]
497
+ return if type_val.nil? || type_val.is_a?(String) # Allow nil or String (e.g., "string", "number", "boolean")
498
+
499
+ raise ArgumentError, "Prompt argument :type at index #{idx} must be a String if provided (e.g., JSON schema type). Found: #{type_val.class}"
500
+ end
501
+
502
+ # Checks for any unknown keys in a prompt argument definition.
503
+ # @api private
504
+ def validate_prompt_arg_unknown_keys!(arg, idx)
505
+ unknown_keys = arg.transform_keys(&:to_s).keys - ALLOWED_PROMPT_ARG_KEYS
506
+ return if unknown_keys.empty?
507
+
508
+ # rubocop:disable Layout/LineLength
509
+ raise ArgumentError,
510
+ "Prompt argument definition at index #{idx} contains unknown keys: #{unknown_keys.join(", ")}. Allowed: #{ALLOWED_PROMPT_ARG_KEYS.join(", ")}."
511
+ # rubocop:enable Layout/LineLength
512
+ end
513
+ end
514
+
515
+ module Transport
516
+ # Dummy base class placeholder used only for argument validation in tests.
517
+ # Real transport classes (e.g., Stdio, SSE) are separate concrete classes.
518
+ class Base # :nodoc:
519
+ end
520
+ end
521
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VectorMCP
4
+ # Represents the state of a single client-server connection session in MCP.
5
+ # It tracks initialization status, and negotiated capabilities between the client and server.
6
+ #
7
+ # @attr_reader server_info [Hash] Information about the server.
8
+ # @attr_reader server_capabilities [Hash] Capabilities supported by the server.
9
+ # @attr_reader protocol_version [String] The MCP protocol version used by the server.
10
+ # @attr_reader client_info [Hash, nil] Information about the client, received during initialization.
11
+ # @attr_reader client_capabilities [Hash, nil] Capabilities supported by the client, received during initialization.
12
+ class Session
13
+ attr_reader :server_info, :server_capabilities, :protocol_version, :client_info, :client_capabilities
14
+
15
+ # Initializes a new session.
16
+ #
17
+ # @param server_info [Hash] Hash containing server information (e.g., name, version).
18
+ # @param server_capabilities [Hash] Hash describing server capabilities.
19
+ # @param protocol_version [String] The protocol version the server adheres to.
20
+ def initialize(server_info:, server_capabilities:, protocol_version:)
21
+ @server_info = server_info
22
+ @server_capabilities = server_capabilities
23
+ @protocol_version = protocol_version
24
+
25
+ @initialized = false
26
+ @client_info = nil
27
+ @client_capabilities = nil
28
+ end
29
+
30
+ # Marks the session as initialized using parameters from the client's `initialize` request.
31
+ #
32
+ # @param params [Hash] The parameters from the client's `initialize` request.
33
+ # Expected keys include "protocolVersion", "clientInfo", and "capabilities".
34
+ # @return [Hash] A hash suitable for the server's `initialize` response result.
35
+ def initialize!(params)
36
+ client_protocol_version = params["protocolVersion"]
37
+
38
+ if client_protocol_version != @protocol_version
39
+ # raise VectorMCP::ProtocolError.new("Unsupported protocol version: #{client_protocol_version}", code: -32603)
40
+ VectorMCP.logger.warn("Client requested protocol version '#{client_protocol_version}', server using '#{@protocol_version}'")
41
+ end
42
+
43
+ @client_info = params["clientInfo"] || {}
44
+ @client_capabilities = params["capabilities"] || {}
45
+ @initialized = true
46
+
47
+ # Return the initialize result (will be sent by transport)
48
+ {
49
+ protocolVersion: @protocol_version,
50
+ serverInfo: @server_info,
51
+ capabilities: @server_capabilities
52
+ }
53
+ end
54
+
55
+ # Checks if the session has been successfully initialized.
56
+ #
57
+ # @return [Boolean] True if the session is initialized, false otherwise.
58
+ def initialized?
59
+ @initialized
60
+ end
61
+
62
+ # Helper to check client capabilities later if needed
63
+ # def supports?(capability_key)
64
+ # @client_capabilities.key?(capability_key.to_s)
65
+ # end
66
+ end
67
+ end