vector_mcp 0.4.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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -1
  3. data/README.md +39 -0
  4. data/lib/vector_mcp/definitions.rb +30 -0
  5. data/lib/vector_mcp/handlers/core.rb +32 -89
  6. data/lib/vector_mcp/invocation.rb +106 -0
  7. data/lib/vector_mcp/middleware/anonymizer.rb +186 -0
  8. data/lib/vector_mcp/middleware/hook.rb +7 -24
  9. data/lib/vector_mcp/middleware.rb +26 -9
  10. data/lib/vector_mcp/request_context.rb +97 -14
  11. data/lib/vector_mcp/security/auth_manager.rb +15 -14
  12. data/lib/vector_mcp/security/auth_result.rb +33 -0
  13. data/lib/vector_mcp/security/authorization.rb +5 -9
  14. data/lib/vector_mcp/security/middleware.rb +0 -47
  15. data/lib/vector_mcp/security/session_context.rb +11 -27
  16. data/lib/vector_mcp/security/strategies/api_key.rb +7 -52
  17. data/lib/vector_mcp/security/strategies/custom.rb +15 -39
  18. data/lib/vector_mcp/security/strategies/jwt_token.rb +7 -17
  19. data/lib/vector_mcp/server/capabilities.rb +22 -26
  20. data/lib/vector_mcp/server/message_handling.rb +24 -17
  21. data/lib/vector_mcp/server/registry.rb +70 -120
  22. data/lib/vector_mcp/server.rb +53 -19
  23. data/lib/vector_mcp/session.rb +55 -27
  24. data/lib/vector_mcp/token_store.rb +80 -0
  25. data/lib/vector_mcp/transport/base_session_manager.rb +12 -38
  26. data/lib/vector_mcp/transport/http_stream/event_store.rb +14 -16
  27. data/lib/vector_mcp/transport/http_stream/session_manager.rb +20 -61
  28. data/lib/vector_mcp/transport/http_stream/stream_handler.rb +7 -7
  29. data/lib/vector_mcp/transport/http_stream.rb +95 -55
  30. data/lib/vector_mcp/util/token_sweeper.rb +74 -0
  31. data/lib/vector_mcp/version.rb +1 -1
  32. data/lib/vector_mcp.rb +11 -0
  33. metadata +6 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de0f694bd85fb9d217c352fc907f7d77c27ee3837eea3512c8ca1791c53313c2
4
- data.tar.gz: 86f0a7ab8c95e744ef8d80cb77faddaa195cef6636ea9fe175631b01bc0b0982
3
+ metadata.gz: '09966af580b3bf36b9e17f89ef7b592391c42e63b974a17a1db6a852466b9a56'
4
+ data.tar.gz: 0d04775542e96421bed373179fbfb13864ddfb0aa8256eabea4fddf966d47860
5
5
  SHA512:
6
- metadata.gz: c0f1f384fd9d654cc3385a87937c656dee9beaf38e0aeed6f0e92542366c17051615b774b58d1ecc2eb89dd23e6a6737e85e1dcdfb6d4c239a182d0d3c98f31f
7
- data.tar.gz: 8d0047290e1033266498f197b94b3d51eb88b700c46e44f15229de985a74eb57889f8381887beb92b0007883244de8f0c53b652a9a8939963f30d3c81fecb406
6
+ metadata.gz: 33da62c2ffd5dcab4f5e0f7a74426e52885effb4d09c0d82cb479611df838401bfb0de9edb2777a7bb9f21cca190499fa33ff25ee14c46c42a26183b58efa152
7
+ data.tar.gz: 695aa730c3116bdad34191e3b2b38a0ffea1f10772a2b0e27306f2044b934ab7f576999c13c7bf7568a904af8f82eb8c144830e4a4aebd3aaaec4e8997865430
data/CHANGELOG.md CHANGED
@@ -1,4 +1,48 @@
1
- ## [Unreleased]
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
+
22
+ ## [0.5.0] – 2026-04-22
23
+
24
+ ### Added
25
+
26
+ * **Token-Based Field Anonymization**: Added a general-purpose anonymization pipeline that substitutes sensitive string fields with stable opaque tokens before tool results reach the LLM and restores them on inbound tool invocations.
27
+ - `VectorMCP::TokenStore` — thread-safe bidirectional value ↔ token store with idempotent tokenization.
28
+ - `VectorMCP::Util::TokenSweeper` — stateless recursive traversal utility for parsed JSON structures.
29
+ - `VectorMCP::Middleware::Anonymizer` — middleware wiring the store and sweeper with application-supplied field rules and optional atomic-key handling; registers via `anonymizer.install_on(server)`.
30
+
31
+ * **OAuth 2.1 Resource Server mode (HTTP Stream transport)**: `enable_authentication!` now accepts a `resource_metadata_url:` option. When set, unauthenticated requests to `/mcp` return HTTP `401` with a `WWW-Authenticate: Bearer realm="mcp", resource_metadata="<url>"` header (RFC 9728), enabling MCP clients such as Claude Desktop to discover the authorization server and initiate OAuth 2.1 + PKCE flows. Opt-in: when `resource_metadata_url` is not set, the existing JSON-RPC `-32401` behavior is unchanged. See [docs/oauth_resource_server.md](docs/oauth_resource_server.md) and [docs/rails_oauth_integration.md](docs/rails_oauth_integration.md) for end-to-end guidance.
32
+
33
+ ### Changed
34
+
35
+ * **Auth Result Value Object**: Replaced the internal authentication result hashes with a dedicated `AuthResult` value object for clearer, type-safe handoff between auth strategies and the security middleware.
36
+ * **Consolidated Middleware Hook Types**: `HOOK_TYPES` and `HOOK_OPERATION_TYPES` are now derived from a single source of truth, eliminating drift between the two lists.
37
+ * **Puma Thread Pool Tuning**: Standalone HTTP stream transport now configures the Puma thread pool explicitly for more predictable concurrency behavior.
38
+ * **Event Store Offset Tracking**: Replaced the O(n) event store index rebuild with O(1) offset tracking, reducing overhead for sessions with long event histories.
39
+ * **HTTP Stream Request Tracking**: Removed the redundant `@request_mutex` from HTTP stream request tracking and simplified the surrounding locking.
40
+ * **Handlers::Core Cleanup**: Renamed misleading identifiers and removed dead legacy branches in `Handlers::Core`.
41
+ * **General Complexity Reduction**: Reduced accidental complexity across six refactoring targets in the core request path.
42
+
43
+ ### Fixed
44
+
45
+ * **TokenStore Read-After-Write Consistency**: Closed a read-after-write consistency hole in `TokenStore` so concurrent tokenization and resolution no longer race.
2
46
 
3
47
  ## [0.4.0] – 2026-04-10
4
48
 
data/README.md CHANGED
@@ -14,7 +14,9 @@ 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
19
+ - Token-based field anonymization middleware to keep sensitive values out of LLM context
18
20
 
19
21
  ## Requirements
20
22
 
@@ -161,6 +163,24 @@ end
161
163
 
162
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.
163
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
+
164
184
  ## Security and Middleware
165
185
 
166
186
  VectorMCP keeps security opt-in, but the primitives are built in:
@@ -188,10 +208,27 @@ server.enable_authentication!(strategy: :custom) do |request|
188
208
  end
189
209
  ```
190
210
 
211
+ For MCP clients that speak OAuth 2.1 (e.g. Claude Desktop), pass a `resource_metadata_url:` to turn on RFC 9728 discovery. Unauthenticated requests to `/mcp` return `401` with a `WWW-Authenticate` header pointing at the configured metadata document, and the client drives the rest of the OAuth dance automatically. See [docs/oauth_resource_server.md](./docs/oauth_resource_server.md) for the feature reference and [docs/rails_oauth_integration.md](./docs/rails_oauth_integration.md) for a full Rails + Doorkeeper recipe.
212
+
191
213
  Middleware can hook into tool, resource, prompt, sampling, auth, and transport events, including `before_auth`, `after_auth`, `on_auth_error`, `before_request`, `after_response`, and `on_transport_error`.
192
214
 
193
215
  See [security/README.md](./security/README.md) for the full security guide.
194
216
 
217
+ ### Field Anonymization
218
+
219
+ Keep sensitive string values out of the LLM context by substituting them with stable opaque tokens. Values are tokenized on outbound tool results and restored on inbound tool arguments, so the LLM sees only tokens while your handlers receive the original data.
220
+
221
+ ```ruby
222
+ anonymizer = VectorMCP::Middleware::Anonymizer.new(
223
+ store: VectorMCP::TokenStore.new,
224
+ field_rules: [
225
+ { pattern: /email/i, prefix: "EMAIL" },
226
+ { pattern: /\bssn\b/i, prefix: "SSN" }
227
+ ]
228
+ )
229
+ anonymizer.install_on(server)
230
+ ```
231
+
195
232
  ## Transport Notes
196
233
 
197
234
  - VectorMCP ships with streamable HTTP as its built-in transport
@@ -223,6 +260,8 @@ curl -X POST http://localhost:8080/mcp \
223
260
  - [CHANGELOG.md](./CHANGELOG.md)
224
261
  - [examples/](./examples/)
225
262
  - [docs/rails-setup-guide.md](./docs/rails-setup-guide.md)
263
+ - [docs/rails_oauth_integration.md](./docs/rails_oauth_integration.md)
264
+ - [docs/oauth_resource_server.md](./docs/oauth_resource_server.md)
226
265
  - [docs/streamable-http-spec-compliance.md](./docs/streamable-http-spec-compliance.md)
227
266
  - [security/README.md](./security/README.md)
228
267
  - [MCP Specification](https://modelcontextprotocol.io/)
@@ -29,6 +29,36 @@ module VectorMCP
29
29
  }.compact # Remove nil values
30
30
  end
31
31
 
32
+ # Class method to create a tool whose input_schema already declares a
33
+ # base64-encoded image property. Mirrors Resource.from_image_file and
34
+ # Prompt.with_image_support — keeps schema-building logic with the
35
+ # definition, not with the registry.
36
+ #
37
+ # @param name [String] Unique name for the tool.
38
+ # @param description [String] Human-readable description.
39
+ # @param image_parameter [String] Name of the image parameter.
40
+ # @param additional_parameters [Hash] Additional JSON Schema properties.
41
+ # @param required_parameters [Array<String>] Required parameter names.
42
+ # @param handler [Proc] Tool handler block.
43
+ # @return [Tool]
44
+ def self.with_image_support(name:, description:, image_parameter: "image",
45
+ additional_parameters: {}, required_parameters: [], &handler)
46
+ image_property = {
47
+ type: "string",
48
+ description: "Base64 encoded image data or file path to image",
49
+ contentEncoding: "base64",
50
+ contentMediaType: "image/*"
51
+ }
52
+
53
+ input_schema = {
54
+ type: "object",
55
+ properties: { image_parameter => image_property }.merge(additional_parameters),
56
+ required: required_parameters
57
+ }
58
+
59
+ new(name, description, input_schema, handler)
60
+ end
61
+
32
62
  # Checks if this tool supports image inputs based on its input schema.
33
63
  # @return [Boolean] True if the tool's input schema includes image properties.
34
64
  def supports_image_input?
@@ -55,7 +55,7 @@ module VectorMCP
55
55
  context = create_tool_context(tool_name, params, session, server)
56
56
 
57
57
  begin
58
- session_context = authenticate_session!(session, server, operation_type: :tool_call, operation_name: tool_name)
58
+ session_context = authenticate_request!(session, server, operation_type: :tool_call, operation_name: tool_name)
59
59
 
60
60
  context = server.middleware_manager.execute_hooks(:before_tool_call, context)
61
61
  return handle_middleware_error(context) if context.error?
@@ -64,7 +64,7 @@ module VectorMCP
64
64
  arguments = context.params["arguments"] || {}
65
65
  tool = find_tool!(tool_name, server)
66
66
  authorize_action!(session_context, :call, tool, server)
67
- validate_input_arguments!(tool_name, tool, arguments)
67
+ validate_tool_arguments!(tool_name, tool, arguments)
68
68
 
69
69
  result = execute_tool_handler(tool, arguments, session)
70
70
  context.result = build_tool_result(result)
@@ -105,7 +105,7 @@ module VectorMCP
105
105
  context = create_resource_context(uri_s, params, session, server)
106
106
 
107
107
  begin
108
- session_context = authenticate_session!(session, server, operation_type: :resource_read, operation_name: uri_s)
108
+ session_context = authenticate_request!(session, server, operation_type: :resource_read, operation_name: uri_s)
109
109
 
110
110
  context = server.middleware_manager.execute_hooks(:before_resource_read, context)
111
111
  return handle_middleware_error(context) if context.error?
@@ -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 }
@@ -206,7 +206,7 @@ module VectorMCP
206
206
  prompt = fetch_prompt(prompt_name, server)
207
207
 
208
208
  arguments = context_params["arguments"] || {}
209
- validate_arguments!(prompt_name, prompt, arguments)
209
+ validate_prompt_arguments!(prompt_name, prompt, arguments)
210
210
 
211
211
  # Call the registered handler after arguments were validated
212
212
  result_data = prompt.handler.call(arguments)
@@ -280,7 +280,7 @@ module VectorMCP
280
280
  # @param arguments [Hash] The arguments supplied by the client.
281
281
  # @return [void]
282
282
  # @raise [VectorMCP::InvalidParamsError] if arguments are invalid (e.g., missing, unknown, wrong type).
283
- def self.validate_arguments!(prompt_name, prompt, arguments)
283
+ def self.validate_prompt_arguments!(prompt_name, prompt, arguments)
284
284
  ensure_hash!(prompt_name, arguments, "arguments")
285
285
 
286
286
  arg_defs = prompt.respond_to?(:arguments) ? (prompt.arguments || []) : []
@@ -291,7 +291,7 @@ module VectorMCP
291
291
  raise VectorMCP::InvalidParamsError.new("Invalid arguments",
292
292
  details: build_invalid_arg_details(prompt_name, missing, unknown))
293
293
  end
294
- private_class_method :validate_arguments!
294
+ private_class_method :validate_prompt_arguments!
295
295
 
296
296
  # Ensures a given value is a Hash.
297
297
  # @api private
@@ -369,7 +369,7 @@ module VectorMCP
369
369
  # @param arguments [Hash] The arguments supplied by the client.
370
370
  # @return [void]
371
371
  # @raise [VectorMCP::InvalidParamsError] if arguments fail validation.
372
- def self.validate_input_arguments!(tool_name, tool, arguments)
372
+ def self.validate_tool_arguments!(tool_name, tool, arguments)
373
373
  return unless tool.input_schema.is_a?(Hash)
374
374
  return if tool.input_schema.empty?
375
375
 
@@ -397,72 +397,23 @@ module VectorMCP
397
397
  }
398
398
  )
399
399
  end
400
- private_class_method :validate_input_arguments!
400
+ private_class_method :validate_tool_arguments!
401
401
  private_class_method :raise_tool_validation_error
402
402
 
403
- # Security helper methods
404
-
405
- # Check security for tool access
406
- # @api private
407
- # @param session [VectorMCP::Session] The current session
408
- # @param tool [VectorMCP::Definitions::Tool] The tool being accessed
409
- # @param server [VectorMCP::Server] The server instance
410
- # @return [Hash] Security check result
411
- def self.check_tool_security(session, tool, server)
412
- # Extract request context from session for security middleware
413
- request = extract_request_from_session(session)
414
- server.security_middleware.process_request(request, action: :call, resource: tool)
415
- end
416
- private_class_method :check_tool_security
417
-
418
- # Check security for resource access
419
- # @api private
420
- # @param session [VectorMCP::Session] The current session
421
- # @param resource [VectorMCP::Definitions::Resource] The resource being accessed
422
- # @param server [VectorMCP::Server] The server instance
423
- # @return [Hash] Security check result
424
- def self.check_resource_security(session, resource, server)
425
- request = extract_request_from_session(session)
426
- server.security_middleware.process_request(request, action: :read, resource: resource)
427
- end
428
- private_class_method :check_resource_security
429
-
430
- # Extract request context from session for security processing
403
+ # Extract the request context from the session for security processing.
431
404
  # @api private
432
405
  # @param session [VectorMCP::Session] The current session
433
- # @return [Hash] Request context for security middleware
434
- def self.extract_request_from_session(session)
406
+ # @return [VectorMCP::RequestContext] Request context for security middleware
407
+ def self.extract_auth_credentials(session)
435
408
  # All sessions should have a request_context - this is enforced by Session initialization
436
409
  unless session.respond_to?(:request_context) && session.request_context
437
410
  raise VectorMCP::InternalError,
438
411
  "Session missing request_context - transport layer integration error. Session ID: #{session.id}"
439
412
  end
440
413
 
441
- {
442
- headers: session.request_context.headers,
443
- params: session.request_context.params,
444
- session_id: session.id
445
- }
446
- end
447
- private_class_method :extract_request_from_session
448
-
449
- # Handle security failure by raising appropriate error
450
- # @api private
451
- # @param security_result [Hash] The security check result
452
- # @return [void]
453
- # @raise [VectorMCP::UnauthorizedError, VectorMCP::ForbiddenError]
454
- def self.handle_security_failure(security_result)
455
- case security_result[:error_code]
456
- when "AUTHENTICATION_REQUIRED"
457
- raise VectorMCP::UnauthorizedError, security_result[:error]
458
- when "AUTHORIZATION_FAILED"
459
- raise VectorMCP::ForbiddenError, security_result[:error]
460
- else
461
- # Fallback to generic unauthorized error
462
- raise VectorMCP::UnauthorizedError, security_result[:error] || "Security check failed"
463
- end
414
+ session.request_context
464
415
  end
465
- private_class_method :handle_security_failure
416
+ private_class_method :extract_auth_credentials
466
417
 
467
418
  # Handle middleware error by returning appropriate response or raising error
468
419
  # @api private
@@ -499,24 +450,19 @@ module VectorMCP
499
450
  tool
500
451
  end
501
452
 
502
- # Resolve the session authentication state before operation middleware runs.
503
- def self.authenticate_session!(session, server, operation_type:, operation_name:)
504
- request = extract_request_from_session(session)
505
- context = create_auth_context(operation_type, operation_name, request, session, server)
453
+ # Authenticate the current request and return the caller's identity.
454
+ def self.authenticate_request!(session, server, operation_type:, operation_name:)
455
+ request = extract_auth_credentials(session)
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)
506
459
  context = server.middleware_manager.execute_hooks(:before_auth, context)
507
460
  raise context.error if context.error?
508
461
 
509
- request = context.params
510
- session_context = if server.security_middleware.respond_to?(:authenticate_request)
511
- server.security_middleware.authenticate_request(request)
512
- else
513
- legacy_result = server.security_middleware.process_request(request)
514
- legacy_result[:session_context]
515
- end
516
- session.security_context = session_context if session.respond_to?(:security_context=)
462
+ session_context = server.security_middleware.authenticate_request(context.params)
463
+ session.security_context = session_context
517
464
 
518
- auth_required = server.respond_to?(:auth_manager) ? server.auth_manager.required? : false
519
- raise VectorMCP::UnauthorizedError, "Authentication required" if auth_required && !session_context.authenticated?
465
+ raise VectorMCP::UnauthorizedError, "Authentication required" if server.auth_manager.required? && !session_context.authenticated?
520
466
 
521
467
  context.result = session_context
522
468
  server.middleware_manager.execute_hooks(:after_auth, context)
@@ -572,25 +518,22 @@ module VectorMCP
572
518
  server.resources[uri_s]
573
519
  end
574
520
 
575
- # Validate resource security
521
+ # Check authorization and raise ForbiddenError if denied
576
522
  def self.authorize_action!(session_context, action, resource, server)
577
- authorized = if server.security_middleware.respond_to?(:authorize_action)
578
- server.security_middleware.authorize_action(session_context, action, resource)
579
- else
580
- legacy_result = server.security_middleware.process_request({}, action: action, resource: resource)
581
- legacy_result[:success]
582
- end
583
- return if authorized
523
+ return if server.security_middleware.authorize_action(session_context, action, resource)
584
524
 
585
525
  raise VectorMCP::ForbiddenError, "Access denied"
586
526
  end
587
527
 
588
- # Execute resource handler with proper arity handling
589
- 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)
590
533
  if [1, -1].include?(resource.handler.arity)
591
534
  resource.handler.call(params)
592
535
  else
593
- resource.handler.call(params, session_context)
536
+ resource.handler.call(params, invocation)
594
537
  end
595
538
  end
596
539
 
@@ -633,7 +576,7 @@ module VectorMCP
633
576
  raise error
634
577
  end
635
578
 
636
- private_class_method :handle_middleware_error, :create_tool_context, :find_tool!, :authenticate_session!,
579
+ private_class_method :handle_middleware_error, :create_tool_context, :find_tool!, :authenticate_request!,
637
580
  :execute_tool_handler, :build_tool_result, :handle_tool_error, :create_resource_context,
638
581
  :find_resource!, :authorize_action!, :execute_resource_handler,
639
582
  :process_resource_content, :handle_resource_error, :create_auth_context,
@@ -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