vector_mcp 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -0
- data/README.md +132 -342
- data/lib/vector_mcp/handlers/core.rb +82 -27
- data/lib/vector_mcp/image_util.rb +53 -5
- data/lib/vector_mcp/log_filter.rb +48 -0
- data/lib/vector_mcp/middleware/base.rb +1 -5
- data/lib/vector_mcp/middleware/context.rb +11 -1
- data/lib/vector_mcp/rails/tool.rb +85 -0
- data/lib/vector_mcp/request_context.rb +1 -1
- data/lib/vector_mcp/security/middleware.rb +2 -2
- data/lib/vector_mcp/security/strategies/api_key.rb +27 -4
- data/lib/vector_mcp/security/strategies/jwt_token.rb +10 -5
- data/lib/vector_mcp/server/capabilities.rb +4 -10
- data/lib/vector_mcp/server/message_handling.rb +2 -2
- data/lib/vector_mcp/server/registry.rb +36 -4
- data/lib/vector_mcp/server.rb +49 -41
- data/lib/vector_mcp/session.rb +5 -3
- data/lib/vector_mcp/tool.rb +221 -0
- data/lib/vector_mcp/transport/base_session_manager.rb +1 -17
- data/lib/vector_mcp/transport/http_stream/event_store.rb +33 -13
- data/lib/vector_mcp/transport/http_stream/session_manager.rb +39 -14
- data/lib/vector_mcp/transport/http_stream/stream_handler.rb +133 -47
- data/lib/vector_mcp/transport/http_stream.rb +294 -33
- data/lib/vector_mcp/version.rb +1 -1
- data/lib/vector_mcp.rb +7 -8
- metadata +5 -10
- data/lib/vector_mcp/transport/sse/client_connection.rb +0 -113
- data/lib/vector_mcp/transport/sse/message_handler.rb +0 -166
- data/lib/vector_mcp/transport/sse/puma_config.rb +0 -77
- data/lib/vector_mcp/transport/sse/stream_manager.rb +0 -92
- data/lib/vector_mcp/transport/sse.rb +0 -377
- data/lib/vector_mcp/transport/sse_session_manager.rb +0 -188
- data/lib/vector_mcp/transport/stdio.rb +0 -473
- data/lib/vector_mcp/transport/stdio_session_manager.rb +0 -181
data/lib/vector_mcp/server.rb
CHANGED
|
@@ -5,8 +5,6 @@ require "logger"
|
|
|
5
5
|
require_relative "definitions"
|
|
6
6
|
require_relative "session"
|
|
7
7
|
require_relative "errors"
|
|
8
|
-
require_relative "transport/stdio" # Default transport
|
|
9
|
-
# require_relative "transport/sse" # Load on demand to avoid async dependencies
|
|
10
8
|
require_relative "handlers/core" # Default handlers
|
|
11
9
|
require_relative "util" # Needed if not using Handlers::Core
|
|
12
10
|
require_relative "server/registry"
|
|
@@ -26,7 +24,7 @@ module VectorMCP
|
|
|
26
24
|
# It manages tools, resources, prompts, and handles the MCP message lifecycle.
|
|
27
25
|
#
|
|
28
26
|
# A server instance is typically initialized, configured with capabilities (tools,
|
|
29
|
-
# resources, prompts), and then run with a chosen transport mechanism (e.g.,
|
|
27
|
+
# resources, prompts), and then run with a chosen transport mechanism (e.g., HttpStream).
|
|
30
28
|
#
|
|
31
29
|
# @example Creating and running a simple server
|
|
32
30
|
# server = VectorMCP::Server.new(name: "MySimpleServer", version: "1.0")
|
|
@@ -39,7 +37,7 @@ module VectorMCP
|
|
|
39
37
|
# args["message"]
|
|
40
38
|
# end
|
|
41
39
|
#
|
|
42
|
-
# server.run
|
|
40
|
+
# server.run # Runs with HttpStream transport by default
|
|
43
41
|
#
|
|
44
42
|
# @!attribute [r] logger
|
|
45
43
|
# @return [Logger] The logger instance for this server.
|
|
@@ -68,7 +66,10 @@ module VectorMCP
|
|
|
68
66
|
include MessageHandling
|
|
69
67
|
|
|
70
68
|
# The specific version of the Model Context Protocol this server implements.
|
|
71
|
-
PROTOCOL_VERSION = "
|
|
69
|
+
PROTOCOL_VERSION = "2025-11-25"
|
|
70
|
+
|
|
71
|
+
# All protocol versions this server accepts via the MCP-Protocol-Version header.
|
|
72
|
+
SUPPORTED_PROTOCOL_VERSIONS = %w[2025-11-25 2025-03-26 2024-11-05].freeze
|
|
72
73
|
|
|
73
74
|
attr_reader :logger, :name, :version, :protocol_version, :tools, :resources, :prompts, :roots, :in_flight_requests,
|
|
74
75
|
:auth_manager, :authorization, :security_middleware, :middleware_manager
|
|
@@ -134,33 +135,19 @@ module VectorMCP
|
|
|
134
135
|
|
|
135
136
|
# Runs the server using the specified transport mechanism.
|
|
136
137
|
#
|
|
137
|
-
# @param transport [:
|
|
138
|
-
# Can be
|
|
139
|
-
# If
|
|
140
|
-
#
|
|
141
|
-
# If `:http_stream` is chosen, it uses the MCP-compliant streamable HTTP transport.
|
|
142
|
-
# @param options [Hash] Transport-specific options (e.g., `:host`, `:port` for HTTP transports).
|
|
138
|
+
# @param transport [:http_stream, VectorMCP::Transport::Base] The transport to use.
|
|
139
|
+
# Can be the symbol `:http_stream` or an initialized transport instance.
|
|
140
|
+
# If `:http_stream` is provided, the method will instantiate the MCP-compliant streamable HTTP transport.
|
|
141
|
+
# @param options [Hash] Transport-specific options (e.g., `:host`, `:port`).
|
|
143
142
|
# These are passed to the transport's constructor if a symbol is provided for `transport`.
|
|
144
143
|
# @return [void]
|
|
145
144
|
# @raise [ArgumentError] if an unsupported transport symbol is given.
|
|
146
|
-
|
|
147
|
-
def run(transport: :stdio, **options)
|
|
145
|
+
def run(transport: :http_stream, **)
|
|
148
146
|
active_transport = case transport
|
|
149
|
-
when :stdio
|
|
150
|
-
VectorMCP::Transport::Stdio.new(self, **options)
|
|
151
|
-
when :sse
|
|
152
|
-
begin
|
|
153
|
-
require_relative "transport/sse"
|
|
154
|
-
logger.warn("SSE transport is deprecated. Please use :http_stream instead.")
|
|
155
|
-
VectorMCP::Transport::SSE.new(self, **options)
|
|
156
|
-
rescue LoadError => e
|
|
157
|
-
logger.fatal("SSE transport requires additional dependencies.")
|
|
158
|
-
raise NotImplementedError, "SSE transport dependencies not available: #{e.message}"
|
|
159
|
-
end
|
|
160
147
|
when :http_stream
|
|
161
148
|
begin
|
|
162
149
|
require_relative "transport/http_stream"
|
|
163
|
-
VectorMCP::Transport::HttpStream.new(self, **
|
|
150
|
+
VectorMCP::Transport::HttpStream.new(self, **)
|
|
164
151
|
rescue LoadError => e
|
|
165
152
|
logger.fatal("HttpStream transport requires additional dependencies.")
|
|
166
153
|
raise NotImplementedError, "HttpStream transport dependencies not available: #{e.message}"
|
|
@@ -176,6 +163,26 @@ module VectorMCP
|
|
|
176
163
|
active_transport.run
|
|
177
164
|
end
|
|
178
165
|
|
|
166
|
+
# Returns the MCP server as a Rack application suitable for mounting inside
|
|
167
|
+
# another Rack-based framework (e.g., Rails, Sinatra).
|
|
168
|
+
#
|
|
169
|
+
# Unlike {#run}, this method does NOT start its own HTTP server or block.
|
|
170
|
+
# The returned object responds to `#call(env)` and can be mounted directly:
|
|
171
|
+
#
|
|
172
|
+
# # config/routes.rb (Rails)
|
|
173
|
+
# mount MCP_APP => "/mcp"
|
|
174
|
+
#
|
|
175
|
+
# Call `server.transport.stop` on application shutdown to clean up resources.
|
|
176
|
+
#
|
|
177
|
+
# @param options [Hash] Transport options (e.g., :session_timeout, :event_retention, :allowed_origins)
|
|
178
|
+
# @return [VectorMCP::Transport::HttpStream] A Rack-compatible app
|
|
179
|
+
def rack_app(**)
|
|
180
|
+
require_relative "transport/http_stream"
|
|
181
|
+
active_transport = VectorMCP::Transport::HttpStream.new(self, mounted: true, **)
|
|
182
|
+
self.transport = active_transport
|
|
183
|
+
active_transport
|
|
184
|
+
end
|
|
185
|
+
|
|
179
186
|
# --- Security Configuration ---
|
|
180
187
|
|
|
181
188
|
# Enable authentication with specified strategy and configuration
|
|
@@ -190,7 +197,7 @@ module VectorMCP
|
|
|
190
197
|
|
|
191
198
|
case strategy
|
|
192
199
|
when :api_key
|
|
193
|
-
add_api_key_auth(options[:keys] || [])
|
|
200
|
+
add_api_key_auth(options[:keys] || [], allow_query_params: options[:allow_query_params] || false)
|
|
194
201
|
when :jwt
|
|
195
202
|
add_jwt_auth(options)
|
|
196
203
|
when :custom
|
|
@@ -216,9 +223,9 @@ module VectorMCP
|
|
|
216
223
|
# Enable authorization with optional policy configuration block
|
|
217
224
|
# @param block [Proc] optional block for configuring authorization policies
|
|
218
225
|
# @return [void]
|
|
219
|
-
def enable_authorization!(&
|
|
226
|
+
def enable_authorization!(&)
|
|
220
227
|
@authorization.enable!
|
|
221
|
-
instance_eval(&
|
|
228
|
+
instance_eval(&) if block_given?
|
|
222
229
|
@logger.info("Authorization enabled")
|
|
223
230
|
end
|
|
224
231
|
|
|
@@ -232,29 +239,29 @@ module VectorMCP
|
|
|
232
239
|
# Add authorization policy for tools
|
|
233
240
|
# @param block [Proc] policy block that receives (user, action, tool)
|
|
234
241
|
# @return [void]
|
|
235
|
-
def authorize_tools(&
|
|
236
|
-
@authorization.add_policy(:tool, &
|
|
242
|
+
def authorize_tools(&)
|
|
243
|
+
@authorization.add_policy(:tool, &)
|
|
237
244
|
end
|
|
238
245
|
|
|
239
246
|
# Add authorization policy for resources
|
|
240
247
|
# @param block [Proc] policy block that receives (user, action, resource)
|
|
241
248
|
# @return [void]
|
|
242
|
-
def authorize_resources(&
|
|
243
|
-
@authorization.add_policy(:resource, &
|
|
249
|
+
def authorize_resources(&)
|
|
250
|
+
@authorization.add_policy(:resource, &)
|
|
244
251
|
end
|
|
245
252
|
|
|
246
253
|
# Add authorization policy for prompts
|
|
247
254
|
# @param block [Proc] policy block that receives (user, action, prompt)
|
|
248
255
|
# @return [void]
|
|
249
|
-
def authorize_prompts(&
|
|
250
|
-
@authorization.add_policy(:prompt, &
|
|
256
|
+
def authorize_prompts(&)
|
|
257
|
+
@authorization.add_policy(:prompt, &)
|
|
251
258
|
end
|
|
252
259
|
|
|
253
260
|
# Add authorization policy for roots
|
|
254
261
|
# @param block [Proc] policy block that receives (user, action, root)
|
|
255
262
|
# @return [void]
|
|
256
|
-
def authorize_roots(&
|
|
257
|
-
@authorization.add_policy(:root, &
|
|
263
|
+
def authorize_roots(&)
|
|
264
|
+
@authorization.add_policy(:root, &)
|
|
258
265
|
end
|
|
259
266
|
|
|
260
267
|
# Check if security features are enabled
|
|
@@ -313,9 +320,10 @@ module VectorMCP
|
|
|
313
320
|
|
|
314
321
|
# Add API key authentication strategy
|
|
315
322
|
# @param keys [Array<String>] array of valid API keys
|
|
323
|
+
# @param allow_query_params [Boolean] whether to accept API keys from query parameters
|
|
316
324
|
# @return [void]
|
|
317
|
-
def add_api_key_auth(keys)
|
|
318
|
-
strategy = Security::Strategies::ApiKey.new(keys: keys)
|
|
325
|
+
def add_api_key_auth(keys, allow_query_params: false)
|
|
326
|
+
strategy = Security::Strategies::ApiKey.new(keys: keys, allow_query_params: allow_query_params)
|
|
319
327
|
@auth_manager.add_strategy(:api_key, strategy)
|
|
320
328
|
end
|
|
321
329
|
|
|
@@ -330,8 +338,8 @@ module VectorMCP
|
|
|
330
338
|
# Add custom authentication strategy
|
|
331
339
|
# @param handler [Proc] custom authentication handler block
|
|
332
340
|
# @return [void]
|
|
333
|
-
def add_custom_auth(&
|
|
334
|
-
strategy = Security::Strategies::Custom.new(&
|
|
341
|
+
def add_custom_auth(&)
|
|
342
|
+
strategy = Security::Strategies::Custom.new(&)
|
|
335
343
|
@auth_manager.add_strategy(:custom, strategy)
|
|
336
344
|
end
|
|
337
345
|
|
|
@@ -346,7 +354,7 @@ module VectorMCP
|
|
|
346
354
|
|
|
347
355
|
module Transport
|
|
348
356
|
# Dummy base class placeholder used only for argument validation in tests.
|
|
349
|
-
# Real transport classes (e.g.,
|
|
357
|
+
# Real transport classes (e.g., HttpStream) are separate concrete classes.
|
|
350
358
|
class Base # :nodoc:
|
|
351
359
|
end
|
|
352
360
|
end
|
data/lib/vector_mcp/session.rb
CHANGED
|
@@ -4,6 +4,7 @@ require_relative "sampling/request"
|
|
|
4
4
|
require_relative "sampling/result"
|
|
5
5
|
require_relative "errors"
|
|
6
6
|
require_relative "request_context"
|
|
7
|
+
require_relative "security/session_context"
|
|
7
8
|
|
|
8
9
|
module VectorMCP
|
|
9
10
|
# Represents the state of a single client-server connection session in MCP.
|
|
@@ -17,7 +18,7 @@ module VectorMCP
|
|
|
17
18
|
# @attr_reader request_context [RequestContext] The request context for this session.
|
|
18
19
|
class Session
|
|
19
20
|
attr_reader :server_info, :server_capabilities, :protocol_version, :client_info, :client_capabilities, :server, :transport, :id, :request_context
|
|
20
|
-
attr_accessor :data # For user-defined session-specific storage
|
|
21
|
+
attr_accessor :data, :security_context # For user-defined session-specific storage and resolved auth context
|
|
21
22
|
|
|
22
23
|
# Initializes a new session.
|
|
23
24
|
#
|
|
@@ -33,6 +34,7 @@ module VectorMCP
|
|
|
33
34
|
@client_info = nil
|
|
34
35
|
@client_capabilities = nil
|
|
35
36
|
@data = {} # Initialize user data hash
|
|
37
|
+
@security_context = Security::SessionContext.anonymous
|
|
36
38
|
@logger = server.logger
|
|
37
39
|
|
|
38
40
|
# Initialize request context
|
|
@@ -243,11 +245,11 @@ module VectorMCP
|
|
|
243
245
|
send_request_kwargs = {}
|
|
244
246
|
send_request_kwargs[:timeout] = timeout if timeout
|
|
245
247
|
|
|
246
|
-
#
|
|
248
|
+
# Prefer session-targeted request sending when available
|
|
247
249
|
raw_result = if @transport.respond_to?(:send_request_to_session)
|
|
248
250
|
@transport.send_request_to_session(@id, *send_request_args, **send_request_kwargs)
|
|
249
251
|
else
|
|
250
|
-
# Fallback to generic send_request for
|
|
252
|
+
# Fallback to generic send_request for transports without session targeting
|
|
251
253
|
@transport.send_request(*send_request_args, **send_request_kwargs)
|
|
252
254
|
end
|
|
253
255
|
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
|
|
7
|
+
module VectorMCP
|
|
8
|
+
# Abstract base class for declarative tool definitions.
|
|
9
|
+
#
|
|
10
|
+
# Subclass this to define tools using a class-level DSL instead of
|
|
11
|
+
# the block-based +register_tool+ API. The two styles are fully
|
|
12
|
+
# interchangeable -- both produce the same +VectorMCP::Definitions::Tool+
|
|
13
|
+
# struct that the rest of the system consumes.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# class ListProviders < VectorMCP::Tool
|
|
17
|
+
# tool_name "list_providers"
|
|
18
|
+
# description "List providers filtered by category or status"
|
|
19
|
+
#
|
|
20
|
+
# param :category, type: :string, desc: "Filter by category slug"
|
|
21
|
+
# param :active, type: :boolean, default: true
|
|
22
|
+
#
|
|
23
|
+
# def call(args, session)
|
|
24
|
+
# Provider.where(active: args.fetch("active", true))
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# server.register(ListProviders)
|
|
29
|
+
#
|
|
30
|
+
class Tool
|
|
31
|
+
# Maps Ruby symbol types to JSON Schema property fragments.
|
|
32
|
+
# Each value is merged into the generated property hash, so it may carry
|
|
33
|
+
# both +type+ and +format+ (or any other JSON Schema keyword).
|
|
34
|
+
TYPE_MAP = {
|
|
35
|
+
string: { "type" => "string" },
|
|
36
|
+
integer: { "type" => "integer" },
|
|
37
|
+
number: { "type" => "number" },
|
|
38
|
+
boolean: { "type" => "boolean" },
|
|
39
|
+
array: { "type" => "array" },
|
|
40
|
+
object: { "type" => "object" },
|
|
41
|
+
date: { "type" => "string", "format" => "date" },
|
|
42
|
+
datetime: { "type" => "string", "format" => "date-time" }
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
# Maps Ruby symbol types to a coercer lambda. Types not listed here
|
|
46
|
+
# pass their values through unchanged. Coercers receive the raw value
|
|
47
|
+
# (or nil) and return the coerced value. They must be total over the
|
|
48
|
+
# values JSON Schema validation would accept.
|
|
49
|
+
COERCERS = {
|
|
50
|
+
date: ->(v) { v.nil? || v.is_a?(Date) ? v : Date.parse(v.to_s) },
|
|
51
|
+
datetime: ->(v) { v.nil? || v.is_a?(Time) ? v : Time.parse(v.to_s) }
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
# Ensures each subclass gets its own +@params+ array so sibling
|
|
55
|
+
# classes do not share mutable state.
|
|
56
|
+
def self.inherited(subclass)
|
|
57
|
+
super
|
|
58
|
+
subclass.instance_variable_set(:@params, [])
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Sets or retrieves the tool name.
|
|
62
|
+
#
|
|
63
|
+
# When called with an argument, stores the name.
|
|
64
|
+
# When called without, returns the stored name or derives one from the class name.
|
|
65
|
+
#
|
|
66
|
+
# @param name [String, Symbol, nil] The tool name to set.
|
|
67
|
+
# @return [String] The tool name.
|
|
68
|
+
def self.tool_name(name = nil)
|
|
69
|
+
if name
|
|
70
|
+
@tool_name = name.to_s
|
|
71
|
+
else
|
|
72
|
+
@tool_name || derive_tool_name
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Sets or retrieves the tool description.
|
|
77
|
+
#
|
|
78
|
+
# @param text [String, nil] The description to set.
|
|
79
|
+
# @return [String, nil] The description.
|
|
80
|
+
def self.description(text = nil)
|
|
81
|
+
if text
|
|
82
|
+
@description = text
|
|
83
|
+
else
|
|
84
|
+
@description
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Declares a parameter for the tool's input schema.
|
|
89
|
+
#
|
|
90
|
+
# @param name [Symbol, String] The parameter name.
|
|
91
|
+
# @param type [Symbol] The parameter type (:string, :integer, :number, :boolean, :array, :object).
|
|
92
|
+
# @param desc [String, nil] A human-readable description.
|
|
93
|
+
# @param required [Boolean] Whether the parameter is required (default: false).
|
|
94
|
+
# @param options [Hash] Additional JSON Schema keywords (enum:, default:, format:, items:, etc.).
|
|
95
|
+
def self.param(name, type: :string, desc: nil, required: false, **options)
|
|
96
|
+
@params << {
|
|
97
|
+
name: name.to_s,
|
|
98
|
+
type: type,
|
|
99
|
+
desc: desc,
|
|
100
|
+
required: required,
|
|
101
|
+
options: options
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Builds a +VectorMCP::Definitions::Tool+ struct from the DSL metadata.
|
|
106
|
+
#
|
|
107
|
+
# @return [VectorMCP::Definitions::Tool]
|
|
108
|
+
# @raise [ArgumentError] If the subclass is missing a description or +#call+ method.
|
|
109
|
+
def self.to_definition
|
|
110
|
+
validate_tool_class!
|
|
111
|
+
|
|
112
|
+
VectorMCP::Definitions::Tool.new(
|
|
113
|
+
tool_name,
|
|
114
|
+
description,
|
|
115
|
+
build_input_schema,
|
|
116
|
+
build_handler
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The handler method that subclasses must implement.
|
|
121
|
+
#
|
|
122
|
+
# @param _args [Hash] The tool arguments (string keys).
|
|
123
|
+
# @param _session [VectorMCP::Session] The current session.
|
|
124
|
+
# @return [Object] The tool result.
|
|
125
|
+
def call(_args, _session)
|
|
126
|
+
raise NotImplementedError, "#{self.class.name} must implement #call(args, session)"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Derives a snake_case tool name from the class name.
|
|
130
|
+
def self.derive_tool_name
|
|
131
|
+
base = name&.split("::")&.last
|
|
132
|
+
return "unnamed_tool" unless base
|
|
133
|
+
|
|
134
|
+
base.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
135
|
+
end
|
|
136
|
+
private_class_method :derive_tool_name
|
|
137
|
+
|
|
138
|
+
# Validates that the subclass is properly configured.
|
|
139
|
+
def self.validate_tool_class!
|
|
140
|
+
raise ArgumentError, "#{name || self} must declare a description" unless description
|
|
141
|
+
return if method_defined?(:call) && instance_method(:call).owner != VectorMCP::Tool
|
|
142
|
+
|
|
143
|
+
raise ArgumentError, "#{name || self} must implement #call"
|
|
144
|
+
end
|
|
145
|
+
private_class_method :validate_tool_class!
|
|
146
|
+
|
|
147
|
+
# Builds a 2-arity handler lambda. A new instance is created per invocation.
|
|
148
|
+
# Arguments are coerced based on the declared param types before the
|
|
149
|
+
# handler sees them (e.g. :date param strings become Date objects).
|
|
150
|
+
def self.build_handler
|
|
151
|
+
klass = self
|
|
152
|
+
params = @params
|
|
153
|
+
->(args, session) { klass.new.call(klass.coerce_args(args, params), session) }
|
|
154
|
+
end
|
|
155
|
+
private_class_method :build_handler
|
|
156
|
+
|
|
157
|
+
# Applies coercers to the raw argument hash. Returns a new hash; does
|
|
158
|
+
# not mutate the original. Keys without a coercible type pass through.
|
|
159
|
+
# Keys that are absent from +args+ stay absent — coercion only fires
|
|
160
|
+
# for keys actually present.
|
|
161
|
+
#
|
|
162
|
+
# A parse failure on a client-supplied value is translated into
|
|
163
|
+
# +VectorMCP::InvalidParamsError+ (JSON-RPC -32602) so the client sees
|
|
164
|
+
# a "bad request" response instead of a generic internal error.
|
|
165
|
+
# This is needed because the +json-schema+ gem does not enforce
|
|
166
|
+
# +format: date+ upstream (it does enforce +format: date-time+), so
|
|
167
|
+
# malformed +:date+ values would otherwise crash inside +Date.parse+.
|
|
168
|
+
def self.coerce_args(args, params)
|
|
169
|
+
coerced = args.dup
|
|
170
|
+
params.each do |param|
|
|
171
|
+
name = param[:name]
|
|
172
|
+
next unless coerced.key?(name)
|
|
173
|
+
|
|
174
|
+
coercer = COERCERS[param[:type]]
|
|
175
|
+
next unless coercer
|
|
176
|
+
|
|
177
|
+
begin
|
|
178
|
+
coerced[name] = coercer.call(coerced[name])
|
|
179
|
+
rescue ArgumentError, TypeError => e
|
|
180
|
+
# Date::Error < ArgumentError in Ruby 3.2+, so ArgumentError alone covers Date.parse failures.
|
|
181
|
+
raise VectorMCP::InvalidParamsError.new(
|
|
182
|
+
"Invalid #{param[:type]} value for param '#{name}': #{e.message}",
|
|
183
|
+
details: { param: name, type: param[:type], message: e.message }
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
coerced
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Builds a JSON Schema hash from the declared params.
|
|
191
|
+
def self.build_input_schema
|
|
192
|
+
properties = {}
|
|
193
|
+
required = []
|
|
194
|
+
|
|
195
|
+
@params.each do |param|
|
|
196
|
+
type_fragment = TYPE_MAP.fetch(param[:type]) do
|
|
197
|
+
raise ArgumentError, "Unknown param type :#{param[:type]} for param '#{param[:name]}' in #{name}. " \
|
|
198
|
+
"Valid types: #{TYPE_MAP.keys.join(", ")}"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
prop = type_fragment.dup
|
|
202
|
+
prop["description"] = param[:desc] if param[:desc]
|
|
203
|
+
|
|
204
|
+
param[:options].each do |key, value|
|
|
205
|
+
prop[key.to_s] = value
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
properties[param[:name]] = prop
|
|
209
|
+
required << param[:name] if param[:required]
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
schema = {
|
|
213
|
+
"type" => "object",
|
|
214
|
+
"properties" => properties
|
|
215
|
+
}
|
|
216
|
+
schema["required"] = required unless required.empty?
|
|
217
|
+
schema
|
|
218
|
+
end
|
|
219
|
+
private_class_method :build_input_schema
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -183,22 +183,6 @@ module VectorMCP
|
|
|
183
183
|
end
|
|
184
184
|
end
|
|
185
185
|
|
|
186
|
-
# Broadcasts a message to all sessions that support messaging.
|
|
187
|
-
#
|
|
188
|
-
# @param message [Hash] The message to broadcast
|
|
189
|
-
# @return [Integer] Number of sessions the message was sent to
|
|
190
|
-
def broadcast_message(message)
|
|
191
|
-
count = 0
|
|
192
|
-
@sessions.each_value do |session|
|
|
193
|
-
next unless can_send_message_to_session?(session)
|
|
194
|
-
|
|
195
|
-
count += 1 if message_sent_to_session?(session, message)
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
# Message broadcasted to recipients
|
|
199
|
-
count
|
|
200
|
-
end
|
|
201
|
-
|
|
202
186
|
protected
|
|
203
187
|
|
|
204
188
|
# Hook called when a session is created. Override in subclasses for transport-specific logic.
|
|
@@ -225,7 +209,7 @@ module VectorMCP
|
|
|
225
209
|
end
|
|
226
210
|
|
|
227
211
|
# Determines if this session manager should enable automatic cleanup.
|
|
228
|
-
# Override in subclasses that don't need automatic cleanup (e.g.,
|
|
212
|
+
# Override in subclasses that don't need automatic cleanup (e.g., transports with single persistent session).
|
|
229
213
|
#
|
|
230
214
|
# @return [Boolean] True if auto-cleanup should be enabled
|
|
231
215
|
def auto_cleanup_enabled?
|
|
@@ -17,7 +17,7 @@ module VectorMCP
|
|
|
17
17
|
# @api private
|
|
18
18
|
class EventStore
|
|
19
19
|
# Event data structure
|
|
20
|
-
Event = Struct.new(:id, :data, :type, :timestamp) do
|
|
20
|
+
Event = Struct.new(:id, :data, :type, :timestamp, :session_id, :stream_id) do
|
|
21
21
|
def to_sse_format
|
|
22
22
|
lines = []
|
|
23
23
|
lines << "id: #{id}"
|
|
@@ -44,12 +44,14 @@ module VectorMCP
|
|
|
44
44
|
#
|
|
45
45
|
# @param data [String] The event data
|
|
46
46
|
# @param type [String] The event type (optional)
|
|
47
|
+
# @param session_id [String, nil] The session ID to scope this event to
|
|
48
|
+
# @param stream_id [String, nil] The stream ID to scope this event to
|
|
47
49
|
# @return [String] The generated event ID
|
|
48
|
-
def store_event(data, type = nil)
|
|
50
|
+
def store_event(data, type = nil, session_id: nil, stream_id: nil)
|
|
49
51
|
event_id = generate_event_id
|
|
50
52
|
timestamp = Time.now
|
|
51
53
|
|
|
52
|
-
event = Event.new(event_id, data, type, timestamp)
|
|
54
|
+
event = Event.new(event_id, data, type, timestamp, session_id, stream_id)
|
|
53
55
|
|
|
54
56
|
# Add to events array
|
|
55
57
|
@events.push(event)
|
|
@@ -69,21 +71,39 @@ module VectorMCP
|
|
|
69
71
|
event_id
|
|
70
72
|
end
|
|
71
73
|
|
|
72
|
-
# Retrieves events starting from a specific event ID.
|
|
74
|
+
# Retrieves events starting from a specific event ID, optionally filtered by session.
|
|
73
75
|
#
|
|
74
76
|
# @param last_event_id [String] The last event ID received by client
|
|
77
|
+
# @param session_id [String, nil] Filter events to this session only
|
|
78
|
+
# @param stream_id [String, nil] Filter events to this stream only
|
|
75
79
|
# @return [Array<Event>] Array of events after the specified ID
|
|
76
|
-
def get_events_after(last_event_id)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
def get_events_after(last_event_id, session_id: nil, stream_id: nil)
|
|
81
|
+
events = if last_event_id.nil?
|
|
82
|
+
@events.to_a
|
|
83
|
+
else
|
|
84
|
+
last_index = @event_index[last_event_id]
|
|
85
|
+
return [] if last_index.nil?
|
|
86
|
+
|
|
87
|
+
start_index = last_index + 1
|
|
88
|
+
return [] if start_index >= @events.length
|
|
89
|
+
|
|
90
|
+
@events[start_index..]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
events = events.select { |e| e.session_id == session_id } if session_id
|
|
94
|
+
events = events.select { |e| e.stream_id == stream_id } if stream_id
|
|
95
|
+
events
|
|
96
|
+
end
|
|
81
97
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
98
|
+
# Retrieves a specific event by ID.
|
|
99
|
+
#
|
|
100
|
+
# @param event_id [String] The event ID to look up
|
|
101
|
+
# @return [Event, nil] The stored event, or nil if it is no longer retained
|
|
102
|
+
def get_event(event_id)
|
|
103
|
+
index = @event_index[event_id]
|
|
104
|
+
return nil if index.nil?
|
|
85
105
|
|
|
86
|
-
@events[
|
|
106
|
+
@events[index]
|
|
87
107
|
end
|
|
88
108
|
|
|
89
109
|
# Gets the total number of stored events.
|
|
@@ -34,7 +34,7 @@ module VectorMCP
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
def streaming?
|
|
37
|
-
|
|
37
|
+
!streaming_connection.nil? || !streaming_connections.empty?
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def streaming_connection
|
|
@@ -44,6 +44,25 @@ module VectorMCP
|
|
|
44
44
|
def streaming_connection=(connection)
|
|
45
45
|
metadata[:streaming_connection] = connection
|
|
46
46
|
end
|
|
47
|
+
|
|
48
|
+
def streaming_connections
|
|
49
|
+
metadata[:streaming_connections] ||= Concurrent::Hash.new
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def add_streaming_connection(connection)
|
|
53
|
+
streaming_connections[connection.stream_id] = connection
|
|
54
|
+
self.streaming_connection = connection
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def remove_streaming_connection(connection = nil)
|
|
58
|
+
if connection
|
|
59
|
+
streaming_connections.delete(connection.stream_id)
|
|
60
|
+
self.streaming_connection = streaming_connections.values.first if streaming_connection == connection
|
|
61
|
+
else
|
|
62
|
+
streaming_connections.clear
|
|
63
|
+
self.streaming_connection = nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
47
66
|
end
|
|
48
67
|
|
|
49
68
|
# Initializes a new HTTP stream session manager.
|
|
@@ -64,7 +83,7 @@ module VectorMCP
|
|
|
64
83
|
end
|
|
65
84
|
|
|
66
85
|
# Pre-allocate metadata hash for better performance
|
|
67
|
-
metadata =
|
|
86
|
+
metadata = create_session_metadata
|
|
68
87
|
|
|
69
88
|
# Create internal session record with streaming connection metadata
|
|
70
89
|
session = Session.new(session_id, session_context, now, now, metadata)
|
|
@@ -75,7 +94,9 @@ module VectorMCP
|
|
|
75
94
|
session
|
|
76
95
|
end
|
|
77
96
|
|
|
78
|
-
# Override to add rack_env support
|
|
97
|
+
# Override to add rack_env support.
|
|
98
|
+
# Returns nil when a session_id is provided but not found (expired or unknown).
|
|
99
|
+
# Callers are responsible for returning 404 in that case.
|
|
79
100
|
def get_or_create_session(session_id = nil, rack_env = nil)
|
|
80
101
|
if session_id
|
|
81
102
|
session = get_session(session_id)
|
|
@@ -88,8 +109,8 @@ module VectorMCP
|
|
|
88
109
|
return session
|
|
89
110
|
end
|
|
90
111
|
|
|
91
|
-
#
|
|
92
|
-
return
|
|
112
|
+
# Session ID provided but not found — signal 404 to caller
|
|
113
|
+
return nil
|
|
93
114
|
end
|
|
94
115
|
|
|
95
116
|
create_session(nil, rack_env)
|
|
@@ -129,17 +150,18 @@ module VectorMCP
|
|
|
129
150
|
# @param connection [Object] The streaming connection object
|
|
130
151
|
# @return [void]
|
|
131
152
|
def set_streaming_connection(session, connection)
|
|
132
|
-
session.
|
|
153
|
+
session.add_streaming_connection(connection)
|
|
133
154
|
session.touch!
|
|
134
|
-
logger.debug { "Streaming connection associated: #{session.id}" }
|
|
155
|
+
logger.debug { "Streaming connection associated: #{session.id} (stream #{connection.stream_id})" }
|
|
135
156
|
end
|
|
136
157
|
|
|
137
158
|
# Removes streaming connection from a session.
|
|
138
159
|
#
|
|
139
160
|
# @param session [Session] The session to remove streaming from
|
|
161
|
+
# @param connection [Object, nil] The specific connection to remove, or nil to clear all
|
|
140
162
|
# @return [void]
|
|
141
|
-
def remove_streaming_connection(session)
|
|
142
|
-
session.
|
|
163
|
+
def remove_streaming_connection(session, connection = nil)
|
|
164
|
+
session.remove_streaming_connection(connection)
|
|
143
165
|
session.touch!
|
|
144
166
|
logger.debug { "Streaming connection removed: #{session.id}" }
|
|
145
167
|
end
|
|
@@ -153,7 +175,7 @@ module VectorMCP
|
|
|
153
175
|
|
|
154
176
|
# Override: Returns metadata for new HTTP stream sessions.
|
|
155
177
|
def create_session_metadata
|
|
156
|
-
{ streaming_connection: nil }
|
|
178
|
+
{ streaming_connection: nil, streaming_connections: Concurrent::Hash.new }
|
|
157
179
|
end
|
|
158
180
|
|
|
159
181
|
# Override: Checks if a session can receive messages (has streaming connection).
|
|
@@ -173,15 +195,18 @@ module VectorMCP
|
|
|
173
195
|
# @param session [Session] The session whose connection to close
|
|
174
196
|
# @return [void]
|
|
175
197
|
def close_streaming_connection(session)
|
|
176
|
-
return unless session&.
|
|
198
|
+
return unless session&.streaming?
|
|
199
|
+
|
|
200
|
+
connections = session.streaming_connections.values
|
|
201
|
+
connections = [session.streaming_connection] if connections.empty? && session.streaming_connection
|
|
177
202
|
|
|
178
|
-
|
|
179
|
-
|
|
203
|
+
connections.each do |connection|
|
|
204
|
+
connection.close
|
|
180
205
|
rescue StandardError => e
|
|
181
206
|
logger.warn { "Error closing streaming connection for #{session.id}: #{e.message}" }
|
|
182
207
|
end
|
|
183
208
|
|
|
184
|
-
session.
|
|
209
|
+
session.remove_streaming_connection
|
|
185
210
|
end
|
|
186
211
|
end
|
|
187
212
|
end
|