vector_mcp 0.4.0 → 0.5.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: de0f694bd85fb9d217c352fc907f7d77c27ee3837eea3512c8ca1791c53313c2
4
- data.tar.gz: 86f0a7ab8c95e744ef8d80cb77faddaa195cef6636ea9fe175631b01bc0b0982
3
+ metadata.gz: 8125dfaaa8d0965448eac78401a013a1f5e41292f29141a72c44fca6dbb96cba
4
+ data.tar.gz: cef6523bbd0c43e969102f5776f6bcb5f2e6a9e10507b4faee1839258fe7f39f
5
5
  SHA512:
6
- metadata.gz: c0f1f384fd9d654cc3385a87937c656dee9beaf38e0aeed6f0e92542366c17051615b774b58d1ecc2eb89dd23e6a6737e85e1dcdfb6d4c239a182d0d3c98f31f
7
- data.tar.gz: 8d0047290e1033266498f197b94b3d51eb88b700c46e44f15229de985a74eb57889f8381887beb92b0007883244de8f0c53b652a9a8939963f30d3c81fecb406
6
+ metadata.gz: 6dafbddbeeb279f3232ad45c979b94b92266b1c05060f057dd747bc51ad6cc290da9c30886c9d72cee0445554445cf78e963efd354481974b7b875a5c98e0d21
7
+ data.tar.gz: 68003e44e084dc40cef4ae04add212901c65056d5432a18aad00550fc8055537812693b915b1f6b00c7e409178ea0801d54f3c9fc3a350c3cca5c65f9afcf700
data/CHANGELOG.md CHANGED
@@ -1,4 +1,27 @@
1
- ## [Unreleased]
1
+ ## [0.5.0] – 2026-04-22
2
+
3
+ ### Added
4
+
5
+ * **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.
6
+ - `VectorMCP::TokenStore` — thread-safe bidirectional value ↔ token store with idempotent tokenization.
7
+ - `VectorMCP::Util::TokenSweeper` — stateless recursive traversal utility for parsed JSON structures.
8
+ - `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)`.
9
+
10
+ * **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.
11
+
12
+ ### Changed
13
+
14
+ * **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.
15
+ * **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.
16
+ * **Puma Thread Pool Tuning**: Standalone HTTP stream transport now configures the Puma thread pool explicitly for more predictable concurrency behavior.
17
+ * **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.
18
+ * **HTTP Stream Request Tracking**: Removed the redundant `@request_mutex` from HTTP stream request tracking and simplified the surrounding locking.
19
+ * **Handlers::Core Cleanup**: Renamed misleading identifiers and removed dead legacy branches in `Handlers::Core`.
20
+ * **General Complexity Reduction**: Reduced accidental complexity across six refactoring targets in the core request path.
21
+
22
+ ### Fixed
23
+
24
+ * **TokenStore Read-After-Write Consistency**: Closed a read-after-write consistency hole in `TokenStore` so concurrent tokenization and resolution no longer race.
2
25
 
3
26
  ## [0.4.0] – 2026-04-10
4
27
 
data/README.md CHANGED
@@ -15,6 +15,7 @@ VectorMCP is a Ruby implementation of the Model Context Protocol (MCP) server-si
15
15
  - Rack and Rails mounting through `server.rack_app`
16
16
  - Opt-in authentication and authorization, structured logging, and middleware hooks
17
17
  - Image-aware tools/resources/prompts, roots, and server-initiated sampling
18
+ - Token-based field anonymization middleware to keep sensitive values out of LLM context
18
19
 
19
20
  ## Requirements
20
21
 
@@ -188,10 +189,27 @@ server.enable_authentication!(strategy: :custom) do |request|
188
189
  end
189
190
  ```
190
191
 
192
+ 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.
193
+
191
194
  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
195
 
193
196
  See [security/README.md](./security/README.md) for the full security guide.
194
197
 
198
+ ### Field Anonymization
199
+
200
+ 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.
201
+
202
+ ```ruby
203
+ anonymizer = VectorMCP::Middleware::Anonymizer.new(
204
+ store: VectorMCP::TokenStore.new,
205
+ field_rules: [
206
+ { pattern: /email/i, prefix: "EMAIL" },
207
+ { pattern: /\bssn\b/i, prefix: "SSN" }
208
+ ]
209
+ )
210
+ anonymizer.install_on(server)
211
+ ```
212
+
195
213
  ## Transport Notes
196
214
 
197
215
  - VectorMCP ships with streamable HTTP as its built-in transport
@@ -223,6 +241,8 @@ curl -X POST http://localhost:8080/mcp \
223
241
  - [CHANGELOG.md](./CHANGELOG.md)
224
242
  - [examples/](./examples/)
225
243
  - [docs/rails-setup-guide.md](./docs/rails-setup-guide.md)
244
+ - [docs/rails_oauth_integration.md](./docs/rails_oauth_integration.md)
245
+ - [docs/oauth_resource_server.md](./docs/oauth_resource_server.md)
226
246
  - [docs/streamable-http-spec-compliance.md](./docs/streamable-http-spec-compliance.md)
227
247
  - [security/README.md](./security/README.md)
228
248
  - [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?
@@ -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,41 +397,14 @@ 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
403
  # Extract request context from session for security processing
431
404
  # @api private
432
405
  # @param session [VectorMCP::Session] The current session
433
406
  # @return [Hash] Request context for security middleware
434
- def self.extract_request_from_session(session)
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,
@@ -444,25 +417,7 @@ module VectorMCP
444
417
  session_id: session.id
445
418
  }
446
419
  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
464
- end
465
- private_class_method :handle_security_failure
420
+ private_class_method :extract_auth_credentials
466
421
 
467
422
  # Handle middleware error by returning appropriate response or raising error
468
423
  # @api private
@@ -499,24 +454,17 @@ module VectorMCP
499
454
  tool
500
455
  end
501
456
 
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)
457
+ # Authenticate the current request and return the caller's identity.
458
+ def self.authenticate_request!(session, server, operation_type:, operation_name:)
459
+ request = extract_auth_credentials(session)
505
460
  context = create_auth_context(operation_type, operation_name, request, session, server)
506
461
  context = server.middleware_manager.execute_hooks(:before_auth, context)
507
462
  raise context.error if context.error?
508
463
 
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
464
+ session_context = server.security_middleware.authenticate_request(context.params)
516
465
  session.security_context = session_context if session.respond_to?(:security_context=)
517
466
 
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?
467
+ raise VectorMCP::UnauthorizedError, "Authentication required" if server.auth_manager.required? && !session_context.authenticated?
520
468
 
521
469
  context.result = session_context
522
470
  server.middleware_manager.execute_hooks(:after_auth, context)
@@ -572,15 +520,9 @@ module VectorMCP
572
520
  server.resources[uri_s]
573
521
  end
574
522
 
575
- # Validate resource security
523
+ # Check authorization and raise ForbiddenError if denied
576
524
  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
525
+ return if server.security_middleware.authorize_action(session_context, action, resource)
584
526
 
585
527
  raise VectorMCP::ForbiddenError, "Access denied"
586
528
  end
@@ -633,7 +575,7 @@ module VectorMCP
633
575
  raise error
634
576
  end
635
577
 
636
- private_class_method :handle_middleware_error, :create_tool_context, :find_tool!, :authenticate_session!,
578
+ private_class_method :handle_middleware_error, :create_tool_context, :find_tool!, :authenticate_request!,
637
579
  :execute_tool_handler, :build_tool_result, :handle_tool_error, :create_resource_context,
638
580
  :find_resource!, :authorize_action!, :execute_resource_handler,
639
581
  :process_resource_content, :handle_resource_error, :create_auth_context,
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "base"
6
+ require_relative "../token_store"
7
+ require_relative "../util/token_sweeper"
8
+
9
+ module VectorMCP
10
+ module Middleware
11
+ # Middleware that rewrites selected string fields in outbound tool
12
+ # results into opaque tokens and restores them on inbound tool
13
+ # invocations. All domain knowledge (which keys to match, token
14
+ # prefixes, which keys to treat as atomic blobs) is supplied by the
15
+ # application via the constructor.
16
+ #
17
+ # @example Wiring on a server
18
+ # anonymizer = VectorMCP::Middleware::Anonymizer.new(
19
+ # store: VectorMCP::TokenStore.new,
20
+ # field_rules: [
21
+ # { pattern: /\bname\b/i, prefix: "NAME" },
22
+ # { pattern: /email/i, prefix: "EMAIL" }
23
+ # ],
24
+ # atomic_keys: /address/i
25
+ # )
26
+ # anonymizer.install_on(server)
27
+ class Anonymizer
28
+ # @param store [VectorMCP::TokenStore] the backing token store.
29
+ # @param field_rules [Array<Hash>] an array of +{ pattern: Regexp, prefix: String }+
30
+ # hashes. The pattern is matched against each Hash key whose value is a String.
31
+ # @param atomic_keys [Regexp, nil] optional pattern; Hash values whose parent
32
+ # key matches are serialized and tokenized as a single opaque unit instead
33
+ # of recursed into.
34
+ def initialize(store:, field_rules:, atomic_keys: nil)
35
+ raise ArgumentError, "store is required" if store.nil?
36
+ raise ArgumentError, "field_rules is required" if field_rules.nil?
37
+
38
+ @store = store
39
+ @field_rules = field_rules.map { |rule| validate_rule!(rule) }.freeze
40
+ @atomic_keys = atomic_keys
41
+ end
42
+
43
+ # Tokenize sensitive string fields in an outbound payload.
44
+ #
45
+ # @param obj [Object] a parsed JSON-like Ruby structure.
46
+ # @return [Object] a new structure with matched values replaced by tokens.
47
+ def sweep_outbound(obj)
48
+ replace_atomic_nodes(obj).then do |shaped|
49
+ VectorMCP::Util::TokenSweeper.sweep(shaped) do |value, parent_key|
50
+ rule = rule_for(parent_key)
51
+ rule ? @store.tokenize(value, prefix: rule[:prefix]) : value
52
+ end
53
+ end
54
+ end
55
+
56
+ # Resolve tokens in an inbound payload back to their original values.
57
+ # Unknown token-shaped strings pass through unchanged.
58
+ #
59
+ # @param obj [Object] a parsed JSON-like Ruby structure.
60
+ # @return [Object] a new structure with tokens resolved to original values.
61
+ def sweep_inbound(obj)
62
+ VectorMCP::Util::TokenSweeper.sweep(obj) do |value, _parent_key|
63
+ if @store.token?(value)
64
+ resolved = @store.resolve(value)
65
+ resolved.nil? ? value : resolved
66
+ else
67
+ value
68
+ end
69
+ end
70
+ end
71
+
72
+ # Middleware hook: rewrite tool arguments before the handler runs.
73
+ # @param context [VectorMCP::Middleware::Context]
74
+ def before_tool_call(context)
75
+ return unless context.params.is_a?(Hash)
76
+
77
+ arguments = context.params["arguments"]
78
+ return unless arguments.is_a?(Hash) || arguments.is_a?(Array)
79
+
80
+ context.params = context.params.merge("arguments" => sweep_inbound(arguments))
81
+ end
82
+
83
+ # Middleware hook: tokenize matched fields in the tool result.
84
+ # @param context [VectorMCP::Middleware::Context]
85
+ def after_tool_call(context)
86
+ return if context.result.nil?
87
+
88
+ context.result = sweep_outbound(context.result)
89
+ end
90
+
91
+ # Register this anonymizer instance on +server+ for the tool call
92
+ # lifecycle. Creates a thin adapter class so the middleware manager's
93
+ # argumentless instantiation can still deliver the configured instance.
94
+ #
95
+ # @param server [VectorMCP::Server] the server instance.
96
+ # @param priority [Integer] middleware priority.
97
+ # @return [Class] the adapter class registered with the server.
98
+ def install_on(server, priority: Hook::DEFAULT_PRIORITY)
99
+ instance = self
100
+ adapter = Class.new(Base) do
101
+ define_method(:before_tool_call) { |context| instance.before_tool_call(context) }
102
+ define_method(:after_tool_call) { |context| instance.after_tool_call(context) }
103
+ end
104
+ server.use_middleware(adapter, %i[before_tool_call after_tool_call], priority: priority)
105
+ adapter
106
+ end
107
+
108
+ private
109
+
110
+ def validate_rule!(rule)
111
+ unless rule.is_a?(Hash) && rule[:pattern].is_a?(Regexp) && rule[:prefix].is_a?(String)
112
+ raise ArgumentError,
113
+ "each field_rule must be a Hash with :pattern (Regexp) and :prefix (String)"
114
+ end
115
+
116
+ rule
117
+ end
118
+
119
+ def rule_for(parent_key)
120
+ return nil if parent_key.nil?
121
+
122
+ key_string = parent_key.to_s
123
+ @field_rules.find { |rule| rule[:pattern].match?(key_string) }
124
+ end
125
+
126
+ def atomic_match?(parent_key)
127
+ return false if @atomic_keys.nil? || parent_key.nil?
128
+
129
+ @atomic_keys.match?(parent_key.to_s)
130
+ end
131
+
132
+ # First pass: collapse Hash nodes whose parent key matches +atomic_keys+
133
+ # into a single tokenized string. A fresh traversal avoids entanglement
134
+ # with the field-rule sweep that runs afterwards.
135
+ def replace_atomic_nodes(obj, parent_key = nil, visited = {}.compare_by_identity)
136
+ case obj
137
+ when Hash
138
+ return obj if visited[obj]
139
+
140
+ if atomic_match?(parent_key)
141
+ @store.tokenize(canonical_json(obj), prefix: atomic_prefix_for(parent_key))
142
+ else
143
+ visited[obj] = true
144
+ begin
145
+ obj.each_with_object({}) do |(key, value), out|
146
+ out[key] = replace_atomic_nodes(value, key, visited)
147
+ end
148
+ ensure
149
+ visited.delete(obj)
150
+ end
151
+ end
152
+ when Array
153
+ return obj if visited[obj]
154
+
155
+ visited[obj] = true
156
+ begin
157
+ obj.map { |element| replace_atomic_nodes(element, parent_key, visited) }
158
+ ensure
159
+ visited.delete(obj)
160
+ end
161
+ else
162
+ obj
163
+ end
164
+ end
165
+
166
+ # Atomic nodes use the prefix of the first field rule whose pattern
167
+ # matches the enclosing key. If no field rule matches, fall back to a
168
+ # neutral default so the token is still well-formed.
169
+ def atomic_prefix_for(parent_key)
170
+ rule_for(parent_key)&.dig(:prefix) || "ATOM"
171
+ end
172
+
173
+ def canonical_json(hash)
174
+ JSON.generate(canonicalize(hash))
175
+ end
176
+
177
+ def canonicalize(obj)
178
+ case obj
179
+ when Hash then obj.keys.map(&:to_s).sort.to_h { |k| [k, canonicalize(obj[k] || obj[k.to_sym])] }
180
+ when Array then obj.map { |element| canonicalize(element) }
181
+ else obj
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
@@ -4,7 +4,7 @@ module VectorMCP
4
4
  module Middleware
5
5
  # Represents a single middleware hook with priority and execution logic
6
6
  class Hook
7
- attr_reader :middleware_class, :hook_type, :priority, :conditions
7
+ attr_reader :middleware_class, :hook_type, :operation_type, :priority, :conditions
8
8
 
9
9
  # Default priority for middleware (lower numbers execute first)
10
10
  DEFAULT_PRIORITY = 100
@@ -21,6 +21,8 @@ module VectorMCP
21
21
 
22
22
  validate_hook_type!
23
23
  validate_middleware_class!
24
+
25
+ @operation_type = HOOK_OPERATION_TYPES[@hook_type]
24
26
  end
25
27
 
26
28
  # Execute this hook with the given context
@@ -72,12 +74,12 @@ module VectorMCP
72
74
  raise ArgumentError, "middleware_class must inherit from VectorMCP::Middleware::Base"
73
75
  end
74
76
 
77
+ # Whether this hook's operation_type matches the context's. Transport- and
78
+ # auth-level hooks (operation_type == nil) match any context.
75
79
  def matches_operation_type?(context)
76
- operation_prefix = @hook_type.split("_")[1..].join("_")
77
-
78
- return true if transport_or_auth_hook?(operation_prefix)
80
+ return true if @operation_type.nil?
79
81
 
80
- operation_matches?(operation_prefix, context.operation_type)
82
+ context.operation_type == @operation_type
81
83
  end
82
84
 
83
85
  def condition_matches?(key, value, context)
@@ -91,25 +93,6 @@ module VectorMCP
91
93
  end
92
94
  end
93
95
 
94
- def transport_or_auth_hook?(operation_prefix)
95
- %w[request response transport_error auth auth_error].include?(operation_prefix)
96
- end
97
-
98
- def operation_matches?(operation_prefix, operation_type)
99
- case operation_prefix
100
- when "tool_call", "tool_error"
101
- operation_type == :tool_call
102
- when "resource_read", "resource_error"
103
- operation_type == :resource_read
104
- when "prompt_get", "prompt_error"
105
- operation_type == :prompt_get
106
- when "sampling_request", "sampling_response", "sampling_error"
107
- operation_type == :sampling
108
- else
109
- true
110
- end
111
- end
112
-
113
96
  def operation_condition_matches?(key, value, context)
114
97
  included = Array(value).include?(context.operation_name)
115
98
  key == :only_operations ? included : !included
@@ -12,15 +12,32 @@ module VectorMCP
12
12
  # Middleware system for pluggable hooks around MCP operations
13
13
  # Allows developers to add custom behavior without modifying core code
14
14
  module Middleware
15
- # Hook types available in the system
16
- HOOK_TYPES = %w[
17
- before_tool_call after_tool_call on_tool_error
18
- before_resource_read after_resource_read on_resource_error
19
- before_prompt_get after_prompt_get on_prompt_error
20
- before_sampling_request after_sampling_response on_sampling_error
21
- before_request after_response on_transport_error
22
- before_auth after_auth on_auth_error
23
- ].freeze
15
+ # Maps each hook type to the operation_type it matches. `nil` means the
16
+ # hook is transport- or auth-level and matches any operation_type.
17
+ # This is the single source of truth — HOOK_TYPES is derived from it.
18
+ HOOK_OPERATION_TYPES = {
19
+ "before_tool_call" => :tool_call,
20
+ "after_tool_call" => :tool_call,
21
+ "on_tool_error" => :tool_call,
22
+ "before_resource_read" => :resource_read,
23
+ "after_resource_read" => :resource_read,
24
+ "on_resource_error" => :resource_read,
25
+ "before_prompt_get" => :prompt_get,
26
+ "after_prompt_get" => :prompt_get,
27
+ "on_prompt_error" => :prompt_get,
28
+ "before_sampling_request" => :sampling,
29
+ "after_sampling_response" => :sampling,
30
+ "on_sampling_error" => :sampling,
31
+ "before_request" => nil,
32
+ "after_response" => nil,
33
+ "on_transport_error" => nil,
34
+ "before_auth" => nil,
35
+ "after_auth" => nil,
36
+ "on_auth_error" => nil
37
+ }.freeze
38
+
39
+ # Hook types available in the system (derived from HOOK_OPERATION_TYPES)
40
+ HOOK_TYPES = HOOK_OPERATION_TYPES.keys.freeze
24
41
 
25
42
  # Error raised when invalid hook type is specified
26
43
  class InvalidHookTypeError < VectorMCP::Error
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "auth_result"
4
+
3
5
  module VectorMCP
4
6
  module Security
5
7
  # Manages authentication strategies for VectorMCP servers
@@ -42,25 +44,22 @@ module VectorMCP
42
44
  # Authenticate a request using the specified or default strategy
43
45
  # @param request [Hash] the request object containing headers, params, etc.
44
46
  # @param strategy [Symbol] optional strategy override
45
- # @return [Object, false] authentication result or false if failed
47
+ # @return [AuthResult] the authentication outcome
46
48
  def authenticate(request, strategy: nil)
47
- return { authenticated: true, user: nil } unless @enabled
49
+ return AuthResult.passthrough unless @enabled
48
50
 
49
51
  strategy_name = strategy || @default_strategy
50
52
  auth_strategy = @strategies[strategy_name]
53
+ return AuthResult.failure unless auth_strategy
51
54
 
52
- return { authenticated: false, error: "Unknown strategy: #{strategy_name}" } unless auth_strategy
53
-
54
- begin
55
- result = auth_strategy.authenticate(request)
56
- if result
57
- { authenticated: true, user: result }
58
- else
59
- { authenticated: false, error: "Authentication failed" }
60
- end
61
- rescue StandardError => e
62
- { authenticated: false, error: "Authentication error: #{e.message}" }
55
+ result = auth_strategy.authenticate(request)
56
+ if result == false
57
+ AuthResult.failure
58
+ else
59
+ AuthResult.success(user: result, strategy: strategy_name.to_s)
63
60
  end
61
+ rescue StandardError
62
+ AuthResult.failure
64
63
  end
65
64
 
66
65
  # Check if authentication is required