vector_mcp 0.5.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8125dfaaa8d0965448eac78401a013a1f5e41292f29141a72c44fca6dbb96cba
4
- data.tar.gz: cef6523bbd0c43e969102f5776f6bcb5f2e6a9e10507b4faee1839258fe7f39f
3
+ metadata.gz: '09966af580b3bf36b9e17f89ef7b592391c42e63b974a17a1db6a852466b9a56'
4
+ data.tar.gz: 0d04775542e96421bed373179fbfb13864ddfb0aa8256eabea4fddf966d47860
5
5
  SHA512:
6
- metadata.gz: 6dafbddbeeb279f3232ad45c979b94b92266b1c05060f057dd747bc51ad6cc290da9c30886c9d72cee0445554445cf78e963efd354481974b7b875a5c98e0d21
7
- data.tar.gz: 68003e44e084dc40cef4ae04add212901c65056d5432a18aad00550fc8055537812693b915b1f6b00c7e409178ea0801d54f3c9fc3a350c3cca5c65f9afcf700
6
+ metadata.gz: 33da62c2ffd5dcab4f5e0f7a74426e52885effb4d09c0d82cb479611df838401bfb0de9edb2777a7bb9f21cca190499fa33ff25ee14c46c42a26183b58efa152
7
+ data.tar.gz: 695aa730c3116bdad34191e3b2b38a0ffea1f10772a2b0e27306f2044b934ab7f576999c13c7bf7568a904af8f82eb8c144830e4a4aebd3aaaec4e8997865430
data/CHANGELOG.md CHANGED
@@ -1,3 +1,24 @@
1
+ ## [0.6.0] – 2026-07-19
2
+
3
+ ### Added
4
+
5
+ * **Per-request `VectorMCP::Invocation`**: Each incoming request is now dispatched through its own request-scoped view of the session, carrying that request's `RequestContext` and resolved `Security::SessionContext`. Handlers receive it as their `session` argument; it delegates all session-lived state (`id`, `data`, `initialize!`, `sample`, ...) and additionally exposes the resolved auth API directly (`user`, `authenticated?`, `can?`, ...).
6
+ * **`RequestContext.coerce` and hash-style access**: `RequestContext` is now the single normalization boundary for authentication. It coerces legacy request shapes (raw Rack env, `{headers:, params:}` hashes) in one place and supports `[]`/`dig`/`key?` so custom auth handlers written against the legacy request hash keep working unchanged.
7
+ * **Session lifecycle on `VectorMCP::Session`**: `created_at`, `last_accessed_at`, `touch!`, `expired?(timeout)`, `age`, and a transport-owned `metadata` hash.
8
+
9
+ ### Changed
10
+
11
+ * **Security fix — request identity isolation**: Concurrent POSTs on one session could previously be authenticated against each other's headers, because request data was stored as mutable state on the shared session. Request-scoped data now travels on the per-request `Invocation` and is never written onto the session.
12
+ * **One `Session` class**: The transport-internal `Session` wrapper structs (in `BaseSessionManager` and `HttpStream::SessionManager`) are gone; session managers store and return `VectorMCP::Session` directly. HTTP streaming connection state lives in the `SessionManager::SessionStreaming` module.
13
+ * **Unified handler interface**: Resource handlers' second argument is now the per-request invocation instead of a bare `Security::SessionContext`. Existing handlers keep working — the invocation forwards the full auth API (`user`, `authenticated?`, `can?`, `user_identifier`, ...; note: not `to_h`) — and additionally gain access to session identity and request data.
14
+ * **Session managers no longer auto-create sessions from client-supplied IDs** (session-fixation hardening in `BaseSessionManager`, matching the existing HTTP stream behavior).
15
+ * Authentication strategies consume `RequestContext` directly; the auth request no longer carries the undocumented `:session_id` key.
16
+
17
+ ### Deprecated (removal in 0.7)
18
+
19
+ * The request-scoped API on `Session`: `request_context=`, `update_request_context`, `request_headers?`, `request_params?`, `request_header`, `request_param`. These now emit warnings via `VectorMCP.deprecation_warning`; use the per-request invocation passed to handlers instead.
20
+ * The third `session_id` argument of `Server#handle_message` (now optional, defaults to `session.id`; silent deprecation).
21
+
1
22
  ## [0.5.0] – 2026-04-22
2
23
 
3
24
  ### Added
data/README.md CHANGED
@@ -14,6 +14,7 @@ VectorMCP is a Ruby implementation of the Model Context Protocol (MCP) server-si
14
14
  - Class-based tools via `VectorMCP::Tool`, plus the original block-based `register_tool` API
15
15
  - Rack and Rails mounting through `server.rack_app`
16
16
  - Opt-in authentication and authorization, structured logging, and middleware hooks
17
+ - Request-scoped identity: every request is dispatched through its own invocation, so concurrent requests on one session can never observe each other's headers or auth
17
18
  - Image-aware tools/resources/prompts, roots, and server-initiated sampling
18
19
  - Token-based field anonymization middleware to keep sensitive values out of LLM context
19
20
 
@@ -162,6 +163,24 @@ end
162
163
 
163
164
  `VectorMCP::Tool` also supports `type: :date` and `type: :datetime`, which are validated as strings in JSON Schema and coerced to `Date` and `Time` before `#call` runs.
164
165
 
166
+ Handlers that take a second argument receive the per-request invocation, which exposes session identity, the request's headers/params, and the authenticated user in one place:
167
+
168
+ ```ruby
169
+ server.register_tool(
170
+ name: "whoami",
171
+ description: "Reports the caller's identity",
172
+ input_schema: { type: "object", properties: {} }
173
+ ) do |_args, invocation|
174
+ {
175
+ session: invocation.id,
176
+ user: invocation.user,
177
+ api_key_header: invocation.request_header("X-API-Key")
178
+ }
179
+ end
180
+ ```
181
+
182
+ Resource handlers get the same invocation as their second argument (it also answers the familiar `user` / `authenticated?` / `can?` queries, so handlers written against the older security-context argument keep working unchanged).
183
+
165
184
  ## Security and Middleware
166
185
 
167
186
  VectorMCP keeps security opt-in, but the primitives are built in:
@@ -114,7 +114,7 @@ module VectorMCP
114
114
  resource = find_resource!(uri_s, server)
115
115
  authorize_action!(session_context, :read, resource, server)
116
116
 
117
- content_raw = execute_resource_handler(resource, context.params, session_context)
117
+ content_raw = execute_resource_handler(resource, context.params, session)
118
118
  contents = process_resource_content(content_raw, resource, uri_s)
119
119
 
120
120
  context.result = { contents: contents }
@@ -400,10 +400,10 @@ module VectorMCP
400
400
  private_class_method :validate_tool_arguments!
401
401
  private_class_method :raise_tool_validation_error
402
402
 
403
- # Extract request context from session for security processing
403
+ # Extract the request context from the session for security processing.
404
404
  # @api private
405
405
  # @param session [VectorMCP::Session] The current session
406
- # @return [Hash] Request context for security middleware
406
+ # @return [VectorMCP::RequestContext] Request context for security middleware
407
407
  def self.extract_auth_credentials(session)
408
408
  # All sessions should have a request_context - this is enforced by Session initialization
409
409
  unless session.respond_to?(:request_context) && session.request_context
@@ -411,11 +411,7 @@ module VectorMCP
411
411
  "Session missing request_context - transport layer integration error. Session ID: #{session.id}"
412
412
  end
413
413
 
414
- {
415
- headers: session.request_context.headers,
416
- params: session.request_context.params,
417
- session_id: session.id
418
- }
414
+ session.request_context
419
415
  end
420
416
  private_class_method :extract_auth_credentials
421
417
 
@@ -457,12 +453,14 @@ module VectorMCP
457
453
  # Authenticate the current request and return the caller's identity.
458
454
  def self.authenticate_request!(session, server, operation_type:, operation_name:)
459
455
  request = extract_auth_credentials(session)
460
- context = create_auth_context(operation_type, operation_name, request, session, server)
456
+ # before_auth middleware hooks see (and may modify) the request as a hash;
457
+ # AuthManager coerces it back into a RequestContext at its boundary.
458
+ context = create_auth_context(operation_type, operation_name, request.to_h, session, server)
461
459
  context = server.middleware_manager.execute_hooks(:before_auth, context)
462
460
  raise context.error if context.error?
463
461
 
464
462
  session_context = server.security_middleware.authenticate_request(context.params)
465
- session.security_context = session_context if session.respond_to?(:security_context=)
463
+ session.security_context = session_context
466
464
 
467
465
  raise VectorMCP::UnauthorizedError, "Authentication required" if server.auth_manager.required? && !session_context.authenticated?
468
466
 
@@ -527,12 +525,15 @@ module VectorMCP
527
525
  raise VectorMCP::ForbiddenError, "Access denied"
528
526
  end
529
527
 
530
- # Execute resource handler with proper arity handling
531
- def self.execute_resource_handler(resource, params, session_context)
528
+ # Execute resource handler with proper arity handling.
529
+ # The second argument is the per-request invocation, which also exposes
530
+ # the SessionContext auth API for handlers written against the legacy
531
+ # (params, session_context) signature.
532
+ def self.execute_resource_handler(resource, params, invocation)
532
533
  if [1, -1].include?(resource.handler.arity)
533
534
  resource.handler.call(params)
534
535
  else
535
- resource.handler.call(params, session_context)
536
+ resource.handler.call(params, invocation)
536
537
  end
537
538
  end
538
539
 
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+ require_relative "request_context"
5
+ require_relative "security/session_context"
6
+
7
+ module VectorMCP
8
+ # A per-request view of a {Session}.
9
+ #
10
+ # Carries the request-scoped state — the incoming request's
11
+ # {RequestContext} and the {Security::SessionContext} resolved for it —
12
+ # while delegating session-lived state (identity, initialization,
13
+ # user data, sampling) to the underlying session. Transports build one
14
+ # Invocation per incoming request and pass it through message dispatch,
15
+ # so concurrent requests on the same session each observe their own
16
+ # request data and authentication result.
17
+ #
18
+ # The Invocation intentionally quacks like a {Session}: tool handlers and
19
+ # request/notification hooks receive it as their `session` argument and
20
+ # can keep using `session.id`, `session.data`, `session.sample`,
21
+ # `session.request_header`, and `session.security_context` unchanged.
22
+ class Invocation
23
+ extend Forwardable
24
+
25
+ # @return [VectorMCP::Session] the underlying session this request belongs to
26
+ attr_reader :session
27
+
28
+ # @return [VectorMCP::RequestContext] this request's context (headers, params, ...)
29
+ attr_reader :request_context
30
+
31
+ # @return [VectorMCP::Security::SessionContext] the security context resolved for this request
32
+ attr_accessor :security_context
33
+
34
+ def_delegators :@session,
35
+ :id, :server, :transport, :data,
36
+ :initialized?, :initialize!, :sample,
37
+ :server_info, :server_capabilities, :protocol_version,
38
+ :client_info, :client_capabilities
39
+
40
+ # The resolved auth API is exposed directly so handlers written against
41
+ # Security::SessionContext (the legacy second argument of resource
42
+ # handlers) keep working when they receive the invocation instead.
43
+ def_delegators :@security_context,
44
+ :user, :authenticated, :authenticated?, :auth_strategy, :authenticated_at,
45
+ :permissions, :can?, :can_access?, :add_permission, :add_permissions,
46
+ :remove_permission, :clear_permissions, :user_identifier, :auth_method,
47
+ :auth_recent?
48
+
49
+ # @param session [VectorMCP::Session] the session the request arrived on
50
+ # @param request_context [RequestContext, Hash, nil] this request's context;
51
+ # defaults to the session's stored context when not provided
52
+ def initialize(session, request_context: nil)
53
+ @session = session
54
+ @request_context = request_context ? RequestContext.coerce(request_context) : session.request_context
55
+ @security_context = Security::SessionContext.anonymous
56
+ end
57
+
58
+ # Replace this request's context (e.g. from middleware).
59
+ # @param context [RequestContext, Hash] the new request context
60
+ def request_context=(context)
61
+ @request_context = RequestContext.coerce(context)
62
+ end
63
+
64
+ # Merge attributes into this request's context.
65
+ # @param attributes [Hash] attributes to merge
66
+ # @return [RequestContext] the updated request context
67
+ def update_request_context(**attributes)
68
+ @request_context = @request_context.merge(attributes)
69
+ end
70
+
71
+ # @return [Boolean] whether this request carried any headers
72
+ def request_headers?
73
+ @request_context.headers?
74
+ end
75
+
76
+ # @return [Boolean] whether this request carried any query parameters
77
+ def request_params?
78
+ @request_context.params?
79
+ end
80
+
81
+ # @param name [String] the header name
82
+ # @return [String, nil] the header value for this request
83
+ def request_header(name)
84
+ @request_context.header(name)
85
+ end
86
+
87
+ # @param name [String] the parameter name
88
+ # @return [String, nil] the parameter value for this request
89
+ def request_param(name)
90
+ @request_context.param(name)
91
+ end
92
+
93
+ # Invocations compare by their underlying session, so collections that
94
+ # deduplicate sessions (e.g. prompt subscribers) treat every request
95
+ # view of one session as the same subscriber.
96
+ def ==(other)
97
+ other_session = other.is_a?(Invocation) ? other.session : other
98
+ @session == other_session
99
+ end
100
+ alias eql? ==
101
+
102
+ def hash
103
+ @session.hash
104
+ end
105
+ end
106
+ end
@@ -91,6 +91,25 @@ module VectorMCP
91
91
  http_methods.include?(@method.upcase)
92
92
  end
93
93
 
94
+ # Return a new context with the given attributes merged in. Nested hashes
95
+ # (headers, params, transport_metadata) are merged key-wise; scalar
96
+ # attributes are replaced.
97
+ #
98
+ # @param attributes [Hash] attributes to merge
99
+ # @return [RequestContext] a new, merged context
100
+ def merge(attributes)
101
+ current = to_h
102
+ merged = current.dup
103
+ attributes.each do |key, value|
104
+ merged[key] = if value.is_a?(Hash) && current[key].is_a?(Hash)
105
+ current[key].merge(value)
106
+ else
107
+ value
108
+ end
109
+ end
110
+ RequestContext.new(**merged)
111
+ end
112
+
94
113
  # Create a minimal request context for non-HTTP transports.
95
114
  # This is useful for non-HTTP transports or testing contexts.
96
115
  #
@@ -106,6 +125,73 @@ module VectorMCP
106
125
  )
107
126
  end
108
127
 
128
+ # Coerce an arbitrary request representation into a RequestContext.
129
+ # This is the single boundary where legacy request shapes (raw Rack
130
+ # environments, `{headers:, params:}` hashes with symbol or string keys)
131
+ # are converted; everything downstream of authentication trusts this type.
132
+ #
133
+ # @param request [RequestContext, Hash] the request to coerce
134
+ # @param transport_type [String] transport identifier used when building from a Rack env
135
+ # @return [RequestContext]
136
+ # @raise [ArgumentError] if the input cannot be coerced
137
+ def self.coerce(request, transport_type = "http")
138
+ case request
139
+ when RequestContext
140
+ request
141
+ when Hash
142
+ return from_rack_env(request, transport_type) if request.key?("REQUEST_METHOD")
143
+
144
+ from_normalized_hash(request)
145
+ else
146
+ raise ArgumentError, "Cannot coerce #{request.class} into a VectorMCP::RequestContext"
147
+ end
148
+ end
149
+
150
+ # Build a context from a legacy `{headers:, params:, ...}` hash with either
151
+ # symbol or string keys.
152
+ # @api private
153
+ def self.from_normalized_hash(hash)
154
+ fetch = ->(key) { hash[key] || hash[key.to_s] }
155
+ new(
156
+ headers: fetch.call(:headers) || {},
157
+ params: fetch.call(:params) || {},
158
+ method: fetch.call(:method),
159
+ path: fetch.call(:path),
160
+ transport_metadata: fetch.call(:transport_metadata) || {}
161
+ )
162
+ end
163
+ private_class_method :from_normalized_hash
164
+
165
+ # Hash-style read access for compatibility with custom authentication
166
+ # handlers written against the legacy `{headers:, params:}` request hash.
167
+ #
168
+ # @param key [String, Symbol] one of :headers, :params, :method, :path, :transport_metadata
169
+ # @return [Object, nil]
170
+ def [](key)
171
+ hash_view[key.to_sym]
172
+ end
173
+
174
+ # @param key [String, Symbol] the top-level key to check
175
+ # @return [Boolean]
176
+ def key?(key)
177
+ hash_view.key?(key.to_sym)
178
+ end
179
+ alias has_key? key?
180
+ alias include? key?
181
+ alias member? key?
182
+
183
+ # Dig into the context like a nested hash, e.g. `context.dig(:headers, "X-API-Key")`.
184
+ #
185
+ # @param key [String, Symbol] the top-level key
186
+ # @param rest [Array] further keys passed to the nested value's #dig
187
+ # @return [Object, nil]
188
+ def dig(key, *rest)
189
+ value = self[key]
190
+ return value if rest.empty?
191
+
192
+ value&.dig(*rest)
193
+ end
194
+
109
195
  # Create a request context from a Rack environment.
110
196
  # This is a convenience method for HTTP-based transports.
111
197
  #
@@ -149,25 +235,22 @@ module VectorMCP
149
235
 
150
236
  private
151
237
 
152
- # Normalize headers to ensure consistent format.
153
- #
154
- # @param headers [Hash] Raw headers hash
155
- # @return [Hash] Normalized headers hash
156
- def normalize_headers(headers)
157
- return {} unless headers.is_a?(Hash)
158
-
159
- headers.transform_keys(&:to_s).transform_values { |v| v.nil? ? "" : v.to_s }
238
+ # Memoized nested-hash view backing the hash-style read accessors.
239
+ def hash_view
240
+ @hash_view ||= to_h.freeze
160
241
  end
161
242
 
162
- # Normalize parameters to ensure consistent format.
243
+ # Normalize a headers/params hash to string keys and string values.
163
244
  #
164
- # @param params [Hash] Raw parameters hash
165
- # @return [Hash] Normalized parameters hash
166
- def normalize_params(params)
167
- return {} unless params.is_a?(Hash)
245
+ # @param hash [Hash] Raw hash
246
+ # @return [Hash] Normalized hash
247
+ def normalize_string_map(hash)
248
+ return {} unless hash.is_a?(Hash)
168
249
 
169
- params.transform_keys(&:to_s).transform_values { |v| v.nil? ? "" : v.to_s }
250
+ hash.transform_keys(&:to_s).transform_values { |v| v.nil? ? "" : v.to_s }
170
251
  end
252
+ alias normalize_headers normalize_string_map
253
+ alias normalize_params normalize_string_map
171
254
 
172
255
  # Normalize transport metadata to ensure consistent format.
173
256
  #
@@ -42,7 +42,9 @@ module VectorMCP
42
42
  end
43
43
 
44
44
  # Authenticate a request using the specified or default strategy
45
- # @param request [Hash] the request object containing headers, params, etc.
45
+ # @param request [VectorMCP::RequestContext, Hash] the request. Legacy hash
46
+ # shapes (raw Rack env, {headers:, params:}) are coerced here — the single
47
+ # normalization boundary for the authentication pipeline.
46
48
  # @param strategy [Symbol] optional strategy override
47
49
  # @return [AuthResult] the authentication outcome
48
50
  def authenticate(request, strategy: nil)
@@ -52,7 +54,7 @@ module VectorMCP
52
54
  auth_strategy = @strategies[strategy_name]
53
55
  return AuthResult.failure unless auth_strategy
54
56
 
55
- result = auth_strategy.authenticate(request)
57
+ result = auth_strategy.authenticate(VectorMCP::RequestContext.coerce(request))
56
58
  if result == false
57
59
  AuthResult.failure
58
60
  else
@@ -73,25 +73,6 @@ module VectorMCP
73
73
  }
74
74
  end
75
75
 
76
- # Create a request object from different transport formats
77
- # @param transport_request [Object] the transport-specific request
78
- # @return [Hash] normalized request object
79
- def normalize_request(transport_request)
80
- case transport_request
81
- when Hash
82
- # Check if it's a Rack environment (has REQUEST_METHOD key)
83
- if transport_request.key?("REQUEST_METHOD")
84
- extract_from_rack_env(transport_request)
85
- else
86
- # Already normalized
87
- transport_request
88
- end
89
- else
90
- # Extract from transport-specific request (e.g., custom objects)
91
- extract_request_data(transport_request)
92
- end
93
- end
94
-
95
76
  # Check if security is enabled
96
77
  # @return [Boolean] true if any security features are enabled
97
78
  def security_enabled?
@@ -113,34 +94,6 @@ module VectorMCP
113
94
  }
114
95
  }
115
96
  end
116
-
117
- private
118
-
119
- # Extract request data from transport-specific formats
120
- # @param transport_request [Object] the transport request
121
- # @return [Hash] extracted request data
122
- def extract_request_data(transport_request)
123
- # Handle Rack environment (for HTTP transports)
124
- if transport_request.respond_to?(:[]) && transport_request["REQUEST_METHOD"]
125
- extract_from_rack_env(transport_request)
126
- else
127
- # Default fallback for non-HTTP request formats
128
- { headers: {}, params: {} }
129
- end
130
- end
131
-
132
- # Extract data from Rack environment
133
- # @param env [Hash] the Rack environment
134
- # @return [Hash] extracted request data
135
- def extract_from_rack_env(env)
136
- {
137
- headers: VectorMCP::Util.extract_headers_from_rack_env(env),
138
- params: VectorMCP::Util.extract_params_from_rack_env(env),
139
- method: env["REQUEST_METHOD"],
140
- path: env["PATH_INFO"],
141
- rack_env: env
142
- }
143
- end
144
97
  end
145
98
  end
146
99
  end
@@ -31,10 +31,10 @@ module VectorMCP
31
31
  end
32
32
 
33
33
  # Authenticate a request using API key
34
- # @param request [Hash] the request object
34
+ # @param request [VectorMCP::RequestContext, Hash] the request
35
35
  # @return [Hash, false] user info hash or false if authentication failed
36
36
  def authenticate(request)
37
- api_key = extract_api_key(request)
37
+ api_key = extract_api_key(VectorMCP::RequestContext.coerce(request))
38
38
  return false unless api_key&.length&.positive?
39
39
 
40
40
  if secure_key_match?(api_key)
@@ -72,57 +72,16 @@ module VectorMCP
72
72
  matched
73
73
  end
74
74
 
75
- # Extract API key from various request formats
76
- # @param request [Hash] the request object
75
+ # Extract API key from the request context
76
+ # @param request [VectorMCP::RequestContext] the request context
77
77
  # @return [String, nil] the extracted API key
78
78
  def extract_api_key(request)
79
- headers = normalize_headers(request)
80
-
81
- from_headers = extract_from_headers(headers)
79
+ from_headers = extract_from_headers(request.headers)
82
80
  return from_headers if from_headers
83
81
 
84
82
  return nil unless @allow_query_params
85
83
 
86
- params = normalize_params(request)
87
- extract_from_params(params)
88
- end
89
-
90
- # Normalize headers to handle different formats
91
- # @param request [Hash] the request object
92
- # @return [Hash] normalized headers
93
- def normalize_headers(request)
94
- # Check if it's a Rack environment (has REQUEST_METHOD)
95
- if request["REQUEST_METHOD"]
96
- extract_headers_from_rack_env(request)
97
- else
98
- request[:headers] || request["headers"] || {}
99
- end
100
- end
101
-
102
- # Normalize params to handle different formats
103
- # @param request [Hash] the request object
104
- # @return [Hash] normalized params
105
- def normalize_params(request)
106
- # Check if it's a Rack environment (has REQUEST_METHOD)
107
- if request["REQUEST_METHOD"]
108
- extract_params_from_rack_env(request)
109
- else
110
- request[:params] || request["params"] || {}
111
- end
112
- end
113
-
114
- # Extract headers from Rack environment
115
- # @param env [Hash] the Rack environment
116
- # @return [Hash] normalized headers
117
- def extract_headers_from_rack_env(env)
118
- VectorMCP::Util.extract_headers_from_rack_env(env)
119
- end
120
-
121
- # Extract params from Rack environment
122
- # @param env [Hash] the Rack environment
123
- # @return [Hash] normalized params
124
- def extract_params_from_rack_env(env)
125
- VectorMCP::Util.extract_params_from_rack_env(env)
84
+ extract_from_params(request.params)
126
85
  end
127
86
 
128
87
  # Extract API key from headers
@@ -17,13 +17,16 @@ module VectorMCP
17
17
  end
18
18
 
19
19
  # Authenticate a request using the custom handler.
20
+ # The handler receives a {VectorMCP::RequestContext}, which supports
21
+ # hash-style access (`request[:headers]`, `request[:params]`) for
22
+ # compatibility with handlers written against the legacy request hash.
20
23
  # If the handler returns a Hash with a :user key, the value is extracted
21
24
  # so that AuthManager receives the user data directly.
22
- # @param request [Hash] the request object
25
+ # @param request [VectorMCP::RequestContext, Hash] the request
23
26
  # @return [Object, nil, false] user data or false if authentication failed.
24
27
  # A return of nil (from { user: nil }) signals "authenticated, no user object."
25
28
  def authenticate(request)
26
- result = @handler.call(request)
29
+ result = @handler.call(VectorMCP::RequestContext.coerce(request))
27
30
  return false unless result && result != false
28
31
 
29
32
  return result unless result.is_a?(Hash) && result.key?(:user)
@@ -35,10 +35,10 @@ module VectorMCP
35
35
  end
36
36
 
37
37
  # Authenticate a request using JWT token
38
- # @param request [Hash] the request object
38
+ # @param request [VectorMCP::RequestContext, Hash] the request
39
39
  # @return [Hash, false] decoded JWT payload or false if authentication failed
40
40
  def authenticate(request)
41
- token = extract_token(request)
41
+ token = extract_token(VectorMCP::RequestContext.coerce(request))
42
42
  return false unless token
43
43
 
44
44
  begin
@@ -70,19 +70,18 @@ module VectorMCP
70
70
 
71
71
  private
72
72
 
73
- # Extract JWT token from request
74
- # @param request [Hash] the request object
73
+ # Extract JWT token from the request context
74
+ # @param request [VectorMCP::RequestContext] the request context
75
75
  # @return [String, nil] the extracted token
76
76
  def extract_token(request)
77
- headers = request[:headers] || request["headers"] || {}
77
+ headers = request.headers
78
78
 
79
79
  from_headers = extract_from_auth_header(headers) || extract_from_jwt_header(headers)
80
80
  return from_headers if from_headers
81
81
 
82
82
  return nil unless @allow_query_params
83
83
 
84
- params = request[:params] || request["params"] || {}
85
- extract_from_params(params)
84
+ extract_from_params(request.params)
86
85
  end
87
86
 
88
87
  # Extract token from Authorization header
@@ -10,12 +10,14 @@ module VectorMCP
10
10
  # This is the main dispatch point for messages received by a transport.
11
11
  #
12
12
  # @param message [Hash] The parsed JSON-RPC message object.
13
- # @param session [VectorMCP::Session] The client session associated with this message.
14
- # @param session_id [String] A unique identifier for the underlying transport connection.
13
+ # @param session [VectorMCP::Session, VectorMCP::Invocation] The session (or per-request
14
+ # invocation view of it) this message belongs to.
15
+ # @param session_id [String, nil] Deprecated: defaults to session.id; used only for log prefixes.
15
16
  # @return [Object, nil] For requests, returns the result data to be sent in the JSON-RPC response.
16
17
  # For notifications, returns `nil`.
17
18
  # @raise [VectorMCP::ProtocolError] if the message is invalid or an error occurs during handling.
18
- def handle_message(message, session, session_id)
19
+ def handle_message(message, session, session_id = nil)
20
+ session_id ||= session.id
19
21
  id = message["id"]
20
22
  method = message["method"]
21
23
  params = message["params"] || {}
@@ -83,8 +85,6 @@ module VectorMCP
83
85
  end
84
86
 
85
87
  # Validates that the session is properly initialized for the given request.
86
- # Transports are contractually required to pass a VectorMCP::Session here —
87
- # never the SessionManager wrapper struct.
88
88
  # @api private
89
89
  def validate_session_initialization(id, method, _params, session)
90
90
  return if session.initialized?
@@ -17,7 +17,8 @@ module VectorMCP
17
17
  # @attr_reader client_capabilities [Hash, nil] Capabilities supported by the client, received during initialization.
18
18
  # @attr_reader request_context [RequestContext] The request context for this session.
19
19
  class Session
20
- attr_reader :server_info, :server_capabilities, :protocol_version, :client_info, :client_capabilities, :server, :transport, :id, :request_context
20
+ attr_reader :server_info, :server_capabilities, :protocol_version, :client_info, :client_capabilities, :server, :transport, :id,
21
+ :request_context, :created_at, :last_accessed_at, :metadata
21
22
  attr_accessor :data, :security_context # For user-defined session-specific storage and resolved auth context
22
23
 
23
24
  # Initializes a new session.
@@ -35,6 +36,9 @@ module VectorMCP
35
36
  @client_capabilities = nil
36
37
  @data = {} # Initialize user data hash
37
38
  @security_context = Security::SessionContext.anonymous
39
+ @created_at = Time.now
40
+ @last_accessed_at = @created_at
41
+ @metadata = {} # Transport-owned lifecycle/connection state
38
42
  @logger = server.logger
39
43
 
40
44
  # Initialize request context
@@ -90,14 +94,34 @@ module VectorMCP
90
94
  @initialized_state == :succeeded
91
95
  end
92
96
 
97
+ # --- Lifecycle (managed by the transport's session manager) ---
98
+
99
+ # Records activity on this session, deferring expiry.
100
+ # @return [void]
101
+ def touch!
102
+ @last_accessed_at = Time.now
103
+ end
104
+
105
+ # @param timeout [Numeric] Idle timeout in seconds.
106
+ # @return [Boolean] True when the session has been idle longer than the timeout.
107
+ def expired?(timeout)
108
+ Time.now - @last_accessed_at > timeout
109
+ end
110
+
111
+ # @return [Float] Seconds since the session was created.
112
+ def age
113
+ Time.now - @created_at
114
+ end
115
+
93
116
  # Sets the request context for this session.
94
- # This method should be called by transport layers to populate request-specific data.
95
117
  #
118
+ # @deprecated Will be removed in 0.7. Request-scoped data travels on the
119
+ # per-request {VectorMCP::Invocation} passed to handlers.
96
120
  # @param context [RequestContext, Hash] The request context to set.
97
- # Can be a RequestContext object or a hash of attributes.
98
121
  # @return [RequestContext] The newly set request context.
99
122
  # @raise [ArgumentError] If the context is not a RequestContext or Hash.
100
123
  def request_context=(context)
124
+ deprecated_request_api(:request_context=)
101
125
  @request_context = case context
102
126
  when RequestContext
103
127
  context
@@ -109,53 +133,47 @@ module VectorMCP
109
133
  end
110
134
 
111
135
  # Updates the request context with new data.
112
- # This merges the provided attributes with the existing context.
113
136
  #
137
+ # @deprecated Will be removed in 0.7. Use the per-request invocation's
138
+ # {VectorMCP::Invocation#update_request_context} instead.
114
139
  # @param attributes [Hash] The attributes to merge into the request context.
115
140
  # @return [RequestContext] The updated request context.
116
141
  def update_request_context(**attributes)
117
- current_attrs = @request_context.to_h
118
-
119
- # Deep merge nested hashes like headers and params
120
- merged_attrs = current_attrs.dup
121
- attributes.each do |key, value|
122
- merged_attrs[key] = if value.is_a?(Hash) && current_attrs[key].is_a?(Hash)
123
- current_attrs[key].merge(value)
124
- else
125
- value
126
- end
127
- end
128
-
129
- @request_context = RequestContext.new(**merged_attrs)
142
+ deprecated_request_api(:update_request_context)
143
+ @request_context = @request_context.merge(attributes)
130
144
  end
131
145
 
132
- # Convenience method to check if the session has request headers.
133
- #
134
- # @return [Boolean] True if the request context has headers, false otherwise.
146
+ # @deprecated Will be removed in 0.7. Use the per-request invocation's
147
+ # {VectorMCP::Invocation#request_headers?} instead.
148
+ # @return [Boolean] True if the request context has headers.
135
149
  def request_headers?
150
+ deprecated_request_api(:request_headers?)
136
151
  @request_context.headers?
137
152
  end
138
153
 
139
- # Convenience method to check if the session has request parameters.
140
- #
141
- # @return [Boolean] True if the request context has parameters, false otherwise.
154
+ # @deprecated Will be removed in 0.7. Use the per-request invocation's
155
+ # {VectorMCP::Invocation#request_params?} instead.
156
+ # @return [Boolean] True if the request context has parameters.
142
157
  def request_params?
158
+ deprecated_request_api(:request_params?)
143
159
  @request_context.params?
144
160
  end
145
161
 
146
- # Convenience method to get a request header value.
147
- #
162
+ # @deprecated Will be removed in 0.7. Use the per-request invocation's
163
+ # {VectorMCP::Invocation#request_header} instead.
148
164
  # @param name [String] The header name.
149
165
  # @return [String, nil] The header value or nil if not found.
150
166
  def request_header(name)
167
+ deprecated_request_api(:request_header)
151
168
  @request_context.header(name)
152
169
  end
153
170
 
154
- # Convenience method to get a request parameter value.
155
- #
171
+ # @deprecated Will be removed in 0.7. Use the per-request invocation's
172
+ # {VectorMCP::Invocation#request_param} instead.
156
173
  # @param name [String] The parameter name.
157
174
  # @return [String, nil] The parameter value or nil if not found.
158
175
  def request_param(name)
176
+ deprecated_request_api(:request_param)
159
177
  @request_context.param(name)
160
178
  end
161
179
 
@@ -220,6 +238,16 @@ module VectorMCP
220
238
 
221
239
  private
222
240
 
241
+ # Emit the standard deprecation warning for the request-scoped Session API.
242
+ # @api private
243
+ def deprecated_request_api(method_name)
244
+ VectorMCP.deprecation_warning(
245
+ "Session##{method_name} is deprecated and will be removed in 0.7; " \
246
+ "request-scoped data lives on the per-request invocation passed to handlers " \
247
+ "(e.g. invocation.request_header)"
248
+ )
249
+ end
250
+
223
251
  # Validates that sampling can be performed on this session.
224
252
  # @api private
225
253
  # @raise [StandardError, InitializationError] if preconditions are not met.
@@ -11,21 +11,6 @@ module VectorMCP
11
11
  #
12
12
  # @abstract Subclass and implement transport-specific methods
13
13
  class BaseSessionManager
14
- # Session data structure for unified session management
15
- Session = Struct.new(:id, :context, :created_at, :last_accessed_at, :metadata) do
16
- def touch!
17
- self.last_accessed_at = Time.now
18
- end
19
-
20
- def expired?(timeout)
21
- Time.now - last_accessed_at > timeout
22
- end
23
-
24
- def age
25
- Time.now - created_at
26
- end
27
- end
28
-
29
14
  attr_reader :transport, :session_timeout, :logger
30
15
 
31
16
  # Initializes a new session manager.
@@ -46,7 +31,7 @@ module VectorMCP
46
31
  # Gets an existing session by ID.
47
32
  #
48
33
  # @param session_id [String] The session ID
49
- # @return [Session, nil] The session if found and valid
34
+ # @return [VectorMCP::Session, nil] The session if found and valid
50
35
  def get_session(session_id)
51
36
  return nil unless session_id
52
37
 
@@ -58,40 +43,29 @@ module VectorMCP
58
43
  end
59
44
 
60
45
  # Gets an existing session or creates a new one.
46
+ # When a session_id is provided but not found (expired or unknown), returns
47
+ # nil instead of creating a session with the client-supplied ID — session
48
+ # IDs are always server-generated (prevents session fixation).
61
49
  #
62
50
  # @param session_id [String, nil] The session ID (optional)
63
- # @return [Session] The existing or newly created session
51
+ # @return [VectorMCP::Session, nil] The existing session, a newly created one, or nil
64
52
  def get_or_create_session(session_id = nil)
65
53
  if session_id
66
- session = get_session(session_id)
67
- return session if session
68
-
69
- # If session_id was provided but not found, create with that ID
70
- return create_session(session_id)
54
+ get_session(session_id)
55
+ else
56
+ create_session
71
57
  end
72
-
73
- create_session
74
58
  end
75
59
 
76
60
  # Creates a new session.
77
61
  #
78
62
  # @param session_id [String, nil] Optional specific session ID to use
79
- # @return [Session] The newly created session
63
+ # @return [VectorMCP::Session] The newly created session
80
64
  def create_session(session_id = nil)
81
65
  session_id ||= generate_session_id
82
- now = Time.now
83
-
84
- # Create VectorMCP session context
85
- session_context = VectorMCP::Session.new(@transport.server, @transport, id: session_id)
86
-
87
- # Create internal session record with transport-specific metadata
88
- session = Session.new(
89
- session_id,
90
- session_context,
91
- now,
92
- now,
93
- create_session_metadata
94
- )
66
+
67
+ session = VectorMCP::Session.new(@transport.server, @transport, id: session_id)
68
+ session.metadata.merge!(create_session_metadata)
95
69
 
96
70
  @sessions[session_id] = session
97
71
 
@@ -19,20 +19,9 @@ module VectorMCP
19
19
  #
20
20
  # @api private
21
21
  class SessionManager < BaseSessionManager
22
- # HTTP stream session data structure extending base session
23
- Session = Struct.new(:id, :context, :created_at, :last_accessed_at, :metadata) do
24
- def touch!
25
- self.last_accessed_at = Time.now
26
- end
27
-
28
- def expired?(timeout)
29
- Time.now - last_accessed_at > timeout
30
- end
31
-
32
- def age
33
- Time.now - created_at
34
- end
35
-
22
+ # HTTP-streaming connection state, kept in the session's transport
23
+ # metadata. New sessions are extended with this module at creation.
24
+ module SessionStreaming
36
25
  def streaming?
37
26
  !streaming_connection.nil? || !streaming_connections.empty?
38
27
  end
@@ -65,24 +54,21 @@ module VectorMCP
65
54
  end
66
55
  end
67
56
 
68
- # Initializes a new HTTP stream session manager.
69
- #
70
- # @param transport [HttpStream] The parent transport instance
71
- # @param session_timeout [Integer] Session timeout in seconds
72
-
73
57
  # Creates a new session. RequestContext.from_rack_env handles nil
74
58
  # rack_env by falling back to a minimal context, so we don't need to
75
59
  # branch here.
60
+ #
61
+ # @return [VectorMCP::Session] The newly created session
76
62
  def create_session(session_id = nil, rack_env = nil)
77
63
  session_id ||= generate_session_id
78
- now = Time.now
79
64
 
80
65
  request_context = VectorMCP::RequestContext.from_rack_env(rack_env, "http_stream")
81
- session_context = VectorMCP::Session.new(
66
+ session = VectorMCP::Session.new(
82
67
  @transport.server, @transport, id: session_id, request_context: request_context
83
68
  )
69
+ session.extend(SessionStreaming)
70
+ session.metadata.merge!(create_session_metadata)
84
71
 
85
- session = Session.new(session_id, session_context, now, now, create_session_metadata)
86
72
  @sessions[session_id] = session
87
73
 
88
74
  logger.info { "Session created: #{session_id}" }
@@ -90,25 +76,16 @@ module VectorMCP
90
76
  end
91
77
 
92
78
  # Override to add rack_env support.
93
- # Returns nil when a session_id is provided but not found (expired or unknown).
94
- # Callers are responsible for returning 404 in that case.
79
+ # Returns nil when a session_id is provided but not found (expired or
80
+ # unknown) — callers are responsible for returning 404 in that case.
81
+ # Request-scoped data is never written onto an existing session; each
82
+ # request carries its own context via the Invocation built at dispatch.
95
83
  def get_or_create_session(session_id = nil, rack_env = nil)
96
84
  if session_id
97
- session = get_session(session_id)
98
- if session
99
- # Update existing session context if rack_env is provided
100
- if rack_env
101
- request_context = VectorMCP::RequestContext.from_rack_env(rack_env, "http_stream")
102
- session.context.request_context = request_context
103
- end
104
- return session
105
- end
106
-
107
- # Session ID provided but not found — signal 404 to caller
108
- return nil
85
+ get_session(session_id)
86
+ else
87
+ create_session(nil, rack_env)
109
88
  end
110
-
111
- create_session(nil, rack_env)
112
89
  end
113
90
 
114
91
  # Terminates a session by ID.
@@ -49,7 +49,7 @@ module VectorMCP
49
49
  # Handles a streaming request (GET request for SSE).
50
50
  #
51
51
  # @param env [Hash] The Rack environment
52
- # @param session [SessionManager::Session] The session for this request
52
+ # @param session [VectorMCP::Session] The session for this request
53
53
  # @return [Array] Rack response triplet for SSE
54
54
  def handle_streaming_request(env, session)
55
55
  last_event_id = extract_last_event_id(env)
@@ -65,7 +65,7 @@ module VectorMCP
65
65
 
66
66
  # Sends a message to a specific session.
67
67
  #
68
- # @param session [SessionManager::Session] The target session
68
+ # @param session [VectorMCP::Session] The target session
69
69
  # @param message [Hash] The message to send
70
70
  # @return [Boolean] True if message was sent successfully
71
71
  def send_message_to_session(session, message)
@@ -141,7 +141,7 @@ module VectorMCP
141
141
 
142
142
  # Creates an SSE stream for a session.
143
143
  #
144
- # @param session [SessionManager::Session] The session
144
+ # @param session [VectorMCP::Session] The session
145
145
  # @param last_event_id [String, nil] The last event ID for resumability
146
146
  # @param origin [Symbol] The stream origin (:get or :post)
147
147
  # @return [Enumerator] SSE stream enumerator
@@ -172,7 +172,7 @@ module VectorMCP
172
172
 
173
173
  # Streams events to a client.
174
174
  #
175
- # @param session [SessionManager::Session] The session
175
+ # @param session [VectorMCP::Session] The session
176
176
  # @param yielder [Enumerator::Yielder] The SSE yielder
177
177
  # @param last_event_id [String, nil] The last event ID for resumability
178
178
  # @param stream_id [String] The unique stream identifier
@@ -193,7 +193,7 @@ module VectorMCP
193
193
  #
194
194
  # @param yielder [Enumerator::Yielder] The SSE yielder
195
195
  # @param last_event_id [String] The last event ID received by client
196
- # @param session [SessionManager::Session] The session to filter events for
196
+ # @param session [VectorMCP::Session] The session to filter events for
197
197
  # @param stream_id [String] The logical stream ID being resumed
198
198
  # @return [void]
199
199
  def replay_events(yielder, last_event_id, session, stream_id)
@@ -212,7 +212,7 @@ module VectorMCP
212
212
 
213
213
  # Keeps the connection alive with periodic heartbeat events.
214
214
  #
215
- # @param session [SessionManager::Session] The session
215
+ # @param session [VectorMCP::Session] The session
216
216
  # @param yielder [Enumerator::Yielder] The SSE yielder
217
217
  # @param stream_id [String] The stream ID for event storage
218
218
  # @return [void]
@@ -274,7 +274,7 @@ module VectorMCP
274
274
 
275
275
  # Cleans up a specific connection.
276
276
  #
277
- # @param session [SessionManager::Session] The session to clean up
277
+ # @param session [VectorMCP::Session] The session to clean up
278
278
  # @return [void]
279
279
  def cleanup_connection(session, connection = nil)
280
280
  connection ||= session.streaming_connection
@@ -384,20 +384,17 @@ module VectorMCP
384
384
  end
385
385
 
386
386
  # Runs the configured authentication strategy against the Rack env and returns
387
- # the resulting SessionContext. The request is normalized into the
388
- # +{ headers:, params:, method:, path:, rack_env: }+ hash shape that the rest
389
- # of the codebase's authentication pipeline uses (see
390
- # +VectorMCP::Handlers::Core.extract_request_from_session+), so +:custom+
391
- # strategy handlers see the same contract here as they do on the in-handler
392
- # auth path. Errors in the strategy are logged and treated as unauthenticated
393
- # rather than propagated, so a malformed token can never crash the request
394
- # pipeline.
387
+ # the resulting SessionContext. The request is carried as a
388
+ # {VectorMCP::RequestContext} the same type the in-handler auth path uses
389
+ # so +:custom+ strategy handlers see one contract everywhere. Errors in the
390
+ # strategy are logged and treated as unauthenticated rather than propagated,
391
+ # so a malformed token can never crash the request pipeline.
395
392
  #
396
393
  # @param env [Hash] The Rack environment
397
394
  # @return [VectorMCP::Security::SessionContext]
398
395
  def authenticate_transport_request(env)
399
- normalized_request = @server.security_middleware.normalize_request(env)
400
- @server.security_middleware.authenticate_request(normalized_request)
396
+ request_context = VectorMCP::RequestContext.from_rack_env(env, "http_stream")
397
+ @server.security_middleware.authenticate_request(request_context)
401
398
  rescue StandardError => e
402
399
  VectorMCP.logger_for("security").warn do
403
400
  "OAuth transport auth strategy raised #{e.class}: #{e.message}"
@@ -477,24 +474,35 @@ module VectorMCP
477
474
  return [202, { "Mcp-Session-Id" => session.id }, []]
478
475
  end
479
476
 
477
+ # Each request is dispatched through its own Invocation so that
478
+ # request-scoped state (headers, params, resolved auth) never leaks
479
+ # between concurrent requests on the same session.
480
+ invocation = VectorMCP::Invocation.new(
481
+ session,
482
+ request_context: VectorMCP::RequestContext.from_rack_env(env, "http_stream")
483
+ )
484
+
480
485
  # Notifications: has method, no id -> 202 Accepted with no body (MCP spec requirement)
481
486
  if message["method"] && !message.key?("id")
482
- @server.handle_message(message, session.context, session.id)
487
+ @server.handle_message(message, invocation)
483
488
  return [202, { "Mcp-Session-Id" => session.id }, []]
484
489
  end
485
490
 
486
- result = @server.handle_message(message, session.context, session.id)
491
+ result = @server.handle_message(message, invocation)
487
492
  build_rpc_response(env, result, message["id"], session.id)
488
493
  rescue VectorMCP::ProtocolError => e
489
494
  build_protocol_error_response(env, e, session_id: session.id)
490
495
  end
491
496
 
492
497
  # Resolves or creates the session for a POST request following MCP spec rules:
493
- # - session_id present and known → return existing session (updating request context)
498
+ # - session_id present and known → return existing session
494
499
  # - session_id present but unknown/expired → 404 Not Found
495
500
  # - no session_id + initialize request → create new session
496
501
  # - no session_id + other request → 400 Bad Request
497
502
  #
503
+ # Request-scoped data is NOT written to the session here; each request
504
+ # carries its own context via the Invocation built at dispatch time.
505
+ #
498
506
  # @param session_id [String, nil] Client-supplied Mcp-Session-Id header value
499
507
  # @param message [Hash] Parsed JSON-RPC message
500
508
  # @param env [Hash] Rack environment
@@ -503,14 +511,7 @@ module VectorMCP
503
511
  is_initialize = message.is_a?(Hash) && message["method"] == "initialize"
504
512
 
505
513
  if session_id
506
- session = @session_manager.get_session(session_id)
507
- return not_found_response("Unknown or expired session") unless session
508
-
509
- if env
510
- request_context = VectorMCP::RequestContext.from_rack_env(env, "http_stream")
511
- session.context.request_context = request_context
512
- end
513
- session
514
+ @session_manager.get_session(session_id) || not_found_response("Unknown or expired session")
514
515
  elsif is_initialize
515
516
  @session_manager.create_session(nil, env)
516
517
  else
@@ -1055,7 +1056,7 @@ module VectorMCP
1055
1056
 
1056
1057
  # Finds the first session with an active streaming connection.
1057
1058
  #
1058
- # @return [SessionManager::Session, nil] The first streaming session or nil if none found
1059
+ # @return [VectorMCP::Session, nil] The first streaming session or nil if none found
1059
1060
  def find_streaming_session
1060
1061
  @session_manager.active_session_ids.each do |session_id|
1061
1062
  session = @session_manager.get_session(session_id)
@@ -1066,7 +1067,7 @@ module VectorMCP
1066
1067
 
1067
1068
  # Finds the first available session (streaming or non-streaming).
1068
1069
  #
1069
- # @return [SessionManager::Session, nil] The first available session or nil if none found
1070
+ # @return [VectorMCP::Session, nil] The first available session or nil if none found
1070
1071
  def find_first_session
1071
1072
  session_ids = @session_manager.active_session_ids
1072
1073
  return nil if session_ids.empty?
@@ -2,5 +2,5 @@
2
2
 
3
3
  module VectorMCP
4
4
  # The current version of the VectorMCP gem.
5
- VERSION = "0.5.0"
5
+ VERSION = "0.6.0"
6
6
  end
data/lib/vector_mcp.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "vector_mcp/version"
7
7
  require_relative "vector_mcp/errors"
8
8
  require_relative "vector_mcp/definitions"
9
9
  require_relative "vector_mcp/session"
10
+ require_relative "vector_mcp/invocation"
10
11
  require_relative "vector_mcp/util"
11
12
  require_relative "vector_mcp/util/token_sweeper"
12
13
  require_relative "vector_mcp/token_store"
@@ -61,6 +62,14 @@ module VectorMCP
61
62
  @logger ||= Logger.for("vectormcp")
62
63
  end
63
64
 
65
+ # Emit a deprecation warning. Centralized so tests and applications can
66
+ # intercept or silence deprecations in one place.
67
+ # @param message [String] description of the deprecated usage and its replacement
68
+ # @return [void]
69
+ def deprecation_warning(message)
70
+ warn("[DEPRECATION] VectorMCP: #{message}")
71
+ end
72
+
64
73
  # Creates a new {VectorMCP::Server} instance. This is a **thin wrapper** around
65
74
  # `VectorMCP::Server.new`; it exists purely for syntactic sugar so you can write
66
75
  # `VectorMCP.new` instead of `VectorMCP::Server.new`.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vector_mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergio Bayona
@@ -129,6 +129,7 @@ files:
129
129
  - lib/vector_mcp/errors.rb
130
130
  - lib/vector_mcp/handlers/core.rb
131
131
  - lib/vector_mcp/image_util.rb
132
+ - lib/vector_mcp/invocation.rb
132
133
  - lib/vector_mcp/log_filter.rb
133
134
  - lib/vector_mcp/logger.rb
134
135
  - lib/vector_mcp/middleware.rb