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 +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +19 -0
- data/lib/vector_mcp/handlers/core.rb +14 -13
- data/lib/vector_mcp/invocation.rb +106 -0
- data/lib/vector_mcp/request_context.rb +97 -14
- data/lib/vector_mcp/security/auth_manager.rb +4 -2
- data/lib/vector_mcp/security/middleware.rb +0 -47
- data/lib/vector_mcp/security/strategies/api_key.rb +6 -47
- data/lib/vector_mcp/security/strategies/custom.rb +5 -2
- data/lib/vector_mcp/security/strategies/jwt_token.rb +6 -7
- data/lib/vector_mcp/server/message_handling.rb +5 -5
- data/lib/vector_mcp/session.rb +55 -27
- data/lib/vector_mcp/transport/base_session_manager.rb +12 -38
- data/lib/vector_mcp/transport/http_stream/session_manager.rb +15 -38
- data/lib/vector_mcp/transport/http_stream/stream_handler.rb +7 -7
- data/lib/vector_mcp/transport/http_stream.rb +24 -23
- data/lib/vector_mcp/version.rb +1 -1
- data/lib/vector_mcp.rb +9 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '09966af580b3bf36b9e17f89ef7b592391c42e63b974a17a1db6a852466b9a56'
|
|
4
|
+
data.tar.gz: 0d04775542e96421bed373179fbfb13864ddfb0aa8256eabea4fddf966d47860
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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 [
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
#
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
243
|
+
# Normalize a headers/params hash to string keys and string values.
|
|
163
244
|
#
|
|
164
|
-
# @param
|
|
165
|
-
# @return [Hash] Normalized
|
|
166
|
-
def
|
|
167
|
-
return {} unless
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
76
|
-
# @param request [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 [
|
|
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
|
|
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
|
-
|
|
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
|
|
14
|
-
#
|
|
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?
|
data/lib/vector_mcp/session.rb
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
#
|
|
133
|
-
#
|
|
134
|
-
# @return [Boolean] True if the request context has headers
|
|
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
|
-
#
|
|
140
|
-
#
|
|
141
|
-
# @return [Boolean] True if the request context has parameters
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
|
94
|
-
#
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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 [
|
|
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 [
|
|
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 [
|
|
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 [
|
|
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 [
|
|
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 [
|
|
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 [
|
|
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
|
|
388
|
-
#
|
|
389
|
-
#
|
|
390
|
-
#
|
|
391
|
-
#
|
|
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
|
-
|
|
400
|
-
@server.security_middleware.authenticate_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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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 [
|
|
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 [
|
|
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?
|
data/lib/vector_mcp/version.rb
CHANGED
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.
|
|
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
|