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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +210 -0
- data/bin/console +11 -0
- data/bin/setup +8 -0
- data/lib/vector_mcp/definitions.rb +81 -0
- data/lib/vector_mcp/errors.rb +138 -0
- data/lib/vector_mcp/handlers/core.rb +289 -0
- data/lib/vector_mcp/server.rb +521 -0
- data/lib/vector_mcp/session.rb +67 -0
- data/lib/vector_mcp/transport/sse.rb +663 -0
- data/lib/vector_mcp/transport/stdio.rb +258 -0
- data/lib/vector_mcp/util.rb +113 -0
- data/lib/vector_mcp/version.rb +6 -0
- data/lib/vector_mcp.rb +65 -0
- metadata +131 -0
@@ -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
|