vector_mcp 0.3.4 → 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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +147 -337
  4. data/lib/vector_mcp/definitions.rb +30 -0
  5. data/lib/vector_mcp/handlers/core.rb +78 -81
  6. data/lib/vector_mcp/image_util.rb +34 -11
  7. data/lib/vector_mcp/middleware/anonymizer.rb +186 -0
  8. data/lib/vector_mcp/middleware/base.rb +1 -5
  9. data/lib/vector_mcp/middleware/context.rb +11 -1
  10. data/lib/vector_mcp/middleware/hook.rb +7 -24
  11. data/lib/vector_mcp/middleware.rb +26 -9
  12. data/lib/vector_mcp/rails/tool.rb +85 -0
  13. data/lib/vector_mcp/request_context.rb +1 -1
  14. data/lib/vector_mcp/security/auth_manager.rb +12 -13
  15. data/lib/vector_mcp/security/auth_result.rb +33 -0
  16. data/lib/vector_mcp/security/authorization.rb +5 -9
  17. data/lib/vector_mcp/security/middleware.rb +2 -2
  18. data/lib/vector_mcp/security/session_context.rb +11 -27
  19. data/lib/vector_mcp/security/strategies/api_key.rb +1 -5
  20. data/lib/vector_mcp/security/strategies/custom.rb +10 -37
  21. data/lib/vector_mcp/security/strategies/jwt_token.rb +1 -10
  22. data/lib/vector_mcp/server/capabilities.rb +22 -32
  23. data/lib/vector_mcp/server/message_handling.rb +21 -14
  24. data/lib/vector_mcp/server/registry.rb +102 -120
  25. data/lib/vector_mcp/server.rb +98 -57
  26. data/lib/vector_mcp/session.rb +5 -3
  27. data/lib/vector_mcp/token_store.rb +80 -0
  28. data/lib/vector_mcp/tool.rb +221 -0
  29. data/lib/vector_mcp/transport/base_session_manager.rb +1 -17
  30. data/lib/vector_mcp/transport/http_stream/event_store.rb +29 -17
  31. data/lib/vector_mcp/transport/http_stream/session_manager.rb +41 -36
  32. data/lib/vector_mcp/transport/http_stream/stream_handler.rb +132 -47
  33. data/lib/vector_mcp/transport/http_stream.rb +242 -124
  34. data/lib/vector_mcp/util/token_sweeper.rb +74 -0
  35. data/lib/vector_mcp/version.rb +1 -1
  36. data/lib/vector_mcp.rb +8 -8
  37. metadata +8 -10
  38. data/lib/vector_mcp/transport/sse/client_connection.rb +0 -113
  39. data/lib/vector_mcp/transport/sse/message_handler.rb +0 -166
  40. data/lib/vector_mcp/transport/sse/puma_config.rb +0 -77
  41. data/lib/vector_mcp/transport/sse/stream_manager.rb +0 -92
  42. data/lib/vector_mcp/transport/sse.rb +0 -377
  43. data/lib/vector_mcp/transport/sse_session_manager.rb +0 -188
  44. data/lib/vector_mcp/transport/stdio.rb +0 -473
  45. data/lib/vector_mcp/transport/stdio_session_manager.rb +0 -181
@@ -52,18 +52,21 @@ module VectorMCP
52
52
  # @raise [VectorMCP::ForbiddenError] if authorization fails.
53
53
  def self.call_tool(params, session, server)
54
54
  tool_name = params["name"]
55
- arguments = params["arguments"] || {}
56
-
57
55
  context = create_tool_context(tool_name, params, session, server)
58
- context = server.middleware_manager.execute_hooks(:before_tool_call, context)
59
- return handle_middleware_error(context) if context.error?
60
56
 
61
57
  begin
58
+ session_context = authenticate_request!(session, server, operation_type: :tool_call, operation_name: tool_name)
59
+
60
+ context = server.middleware_manager.execute_hooks(:before_tool_call, context)
61
+ return handle_middleware_error(context) if context.error?
62
+
63
+ tool_name = context.params["name"] || tool_name
64
+ arguments = context.params["arguments"] || {}
62
65
  tool = find_tool!(tool_name, server)
63
- security_result = validate_tool_security!(session, tool, server)
64
- validate_input_arguments!(tool_name, tool, arguments)
66
+ authorize_action!(session_context, :call, tool, server)
67
+ validate_tool_arguments!(tool_name, tool, arguments)
65
68
 
66
- result = execute_tool_handler(tool, arguments, security_result, session)
69
+ result = execute_tool_handler(tool, arguments, session)
67
70
  context.result = build_tool_result(result)
68
71
 
69
72
  context = server.middleware_manager.execute_hooks(:after_tool_call, context)
@@ -99,16 +102,19 @@ module VectorMCP
99
102
  # @raise [VectorMCP::ForbiddenError] if authorization fails.
100
103
  def self.read_resource(params, session, server)
101
104
  uri_s = params["uri"]
102
-
103
105
  context = create_resource_context(uri_s, params, session, server)
104
- context = server.middleware_manager.execute_hooks(:before_resource_read, context)
105
- return handle_middleware_error(context) if context.error?
106
106
 
107
107
  begin
108
+ session_context = authenticate_request!(session, server, operation_type: :resource_read, operation_name: uri_s)
109
+
110
+ context = server.middleware_manager.execute_hooks(:before_resource_read, context)
111
+ return handle_middleware_error(context) if context.error?
112
+
113
+ uri_s = context.params["uri"] || uri_s
108
114
  resource = find_resource!(uri_s, server)
109
- security_result = validate_resource_security!(session, resource, server)
115
+ authorize_action!(session_context, :read, resource, server)
110
116
 
111
- content_raw = execute_resource_handler(resource, params, security_result)
117
+ content_raw = execute_resource_handler(resource, context.params, session_context)
112
118
  contents = process_resource_content(content_raw, resource, uri_s)
113
119
 
114
120
  context.result = { contents: contents }
@@ -195,10 +201,12 @@ module VectorMCP
195
201
  return handle_middleware_error(context) if context.error?
196
202
 
197
203
  begin
204
+ context_params = context.params
205
+ prompt_name = context_params["name"] || prompt_name
198
206
  prompt = fetch_prompt(prompt_name, server)
199
207
 
200
- arguments = params["arguments"] || {}
201
- validate_arguments!(prompt_name, prompt, arguments)
208
+ arguments = context_params["arguments"] || {}
209
+ validate_prompt_arguments!(prompt_name, prompt, arguments)
202
210
 
203
211
  # Call the registered handler after arguments were validated
204
212
  result_data = prompt.handler.call(arguments)
@@ -272,7 +280,7 @@ module VectorMCP
272
280
  # @param arguments [Hash] The arguments supplied by the client.
273
281
  # @return [void]
274
282
  # @raise [VectorMCP::InvalidParamsError] if arguments are invalid (e.g., missing, unknown, wrong type).
275
- def self.validate_arguments!(prompt_name, prompt, arguments)
283
+ def self.validate_prompt_arguments!(prompt_name, prompt, arguments)
276
284
  ensure_hash!(prompt_name, arguments, "arguments")
277
285
 
278
286
  arg_defs = prompt.respond_to?(:arguments) ? (prompt.arguments || []) : []
@@ -283,7 +291,7 @@ module VectorMCP
283
291
  raise VectorMCP::InvalidParamsError.new("Invalid arguments",
284
292
  details: build_invalid_arg_details(prompt_name, missing, unknown))
285
293
  end
286
- private_class_method :validate_arguments!
294
+ private_class_method :validate_prompt_arguments!
287
295
 
288
296
  # Ensures a given value is a Hash.
289
297
  # @api private
@@ -361,7 +369,7 @@ module VectorMCP
361
369
  # @param arguments [Hash] The arguments supplied by the client.
362
370
  # @return [void]
363
371
  # @raise [VectorMCP::InvalidParamsError] if arguments fail validation.
364
- def self.validate_input_arguments!(tool_name, tool, arguments)
372
+ def self.validate_tool_arguments!(tool_name, tool, arguments)
365
373
  return unless tool.input_schema.is_a?(Hash)
366
374
  return if tool.input_schema.empty?
367
375
 
@@ -389,41 +397,14 @@ module VectorMCP
389
397
  }
390
398
  )
391
399
  end
392
- private_class_method :validate_input_arguments!
400
+ private_class_method :validate_tool_arguments!
393
401
  private_class_method :raise_tool_validation_error
394
402
 
395
- # Security helper methods
396
-
397
- # Check security for tool access
398
- # @api private
399
- # @param session [VectorMCP::Session] The current session
400
- # @param tool [VectorMCP::Definitions::Tool] The tool being accessed
401
- # @param server [VectorMCP::Server] The server instance
402
- # @return [Hash] Security check result
403
- def self.check_tool_security(session, tool, server)
404
- # Extract request context from session for security middleware
405
- request = extract_request_from_session(session)
406
- server.security_middleware.process_request(request, action: :call, resource: tool)
407
- end
408
- private_class_method :check_tool_security
409
-
410
- # Check security for resource access
411
- # @api private
412
- # @param session [VectorMCP::Session] The current session
413
- # @param resource [VectorMCP::Definitions::Resource] The resource being accessed
414
- # @param server [VectorMCP::Server] The server instance
415
- # @return [Hash] Security check result
416
- def self.check_resource_security(session, resource, server)
417
- request = extract_request_from_session(session)
418
- server.security_middleware.process_request(request, action: :read, resource: resource)
419
- end
420
- private_class_method :check_resource_security
421
-
422
403
  # Extract request context from session for security processing
423
404
  # @api private
424
405
  # @param session [VectorMCP::Session] The current session
425
406
  # @return [Hash] Request context for security middleware
426
- def self.extract_request_from_session(session)
407
+ def self.extract_auth_credentials(session)
427
408
  # All sessions should have a request_context - this is enforced by Session initialization
428
409
  unless session.respond_to?(:request_context) && session.request_context
429
410
  raise VectorMCP::InternalError,
@@ -436,25 +417,7 @@ module VectorMCP
436
417
  session_id: session.id
437
418
  }
438
419
  end
439
- private_class_method :extract_request_from_session
440
-
441
- # Handle security failure by raising appropriate error
442
- # @api private
443
- # @param security_result [Hash] The security check result
444
- # @return [void]
445
- # @raise [VectorMCP::UnauthorizedError, VectorMCP::ForbiddenError]
446
- def self.handle_security_failure(security_result)
447
- case security_result[:error_code]
448
- when "AUTHENTICATION_REQUIRED"
449
- raise VectorMCP::UnauthorizedError, security_result[:error]
450
- when "AUTHORIZATION_FAILED"
451
- raise VectorMCP::ForbiddenError, security_result[:error]
452
- else
453
- # Fallback to generic unauthorized error
454
- raise VectorMCP::UnauthorizedError, security_result[:error] || "Security check failed"
455
- end
456
- end
457
- private_class_method :handle_security_failure
420
+ private_class_method :extract_auth_credentials
458
421
 
459
422
  # Handle middleware error by returning appropriate response or raising error
460
423
  # @api private
@@ -491,15 +454,27 @@ module VectorMCP
491
454
  tool
492
455
  end
493
456
 
494
- # Validate tool security
495
- def self.validate_tool_security!(session, tool, server)
496
- security_result = check_tool_security(session, tool, server)
497
- handle_security_failure(security_result) unless security_result[:success]
498
- security_result
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)
460
+ context = create_auth_context(operation_type, operation_name, request, session, server)
461
+ context = server.middleware_manager.execute_hooks(:before_auth, context)
462
+ raise context.error if context.error?
463
+
464
+ session_context = server.security_middleware.authenticate_request(context.params)
465
+ session.security_context = session_context if session.respond_to?(:security_context=)
466
+
467
+ raise VectorMCP::UnauthorizedError, "Authentication required" if server.auth_manager.required? && !session_context.authenticated?
468
+
469
+ context.result = session_context
470
+ server.middleware_manager.execute_hooks(:after_auth, context)
471
+ session_context
472
+ rescue StandardError => e
473
+ handle_auth_error(e, context, server)
499
474
  end
500
475
 
501
476
  # Execute tool handler with proper arity handling
502
- def self.execute_tool_handler(tool, arguments, _security_result, session)
477
+ def self.execute_tool_handler(tool, arguments, session)
503
478
  if [1, -1].include?(tool.handler.arity)
504
479
  tool.handler.call(arguments)
505
480
  else
@@ -545,19 +520,19 @@ module VectorMCP
545
520
  server.resources[uri_s]
546
521
  end
547
522
 
548
- # Validate resource security
549
- def self.validate_resource_security!(session, resource, server)
550
- security_result = check_resource_security(session, resource, server)
551
- handle_security_failure(security_result) unless security_result[:success]
552
- security_result
523
+ # Check authorization and raise ForbiddenError if denied
524
+ def self.authorize_action!(session_context, action, resource, server)
525
+ return if server.security_middleware.authorize_action(session_context, action, resource)
526
+
527
+ raise VectorMCP::ForbiddenError, "Access denied"
553
528
  end
554
529
 
555
530
  # Execute resource handler with proper arity handling
556
- def self.execute_resource_handler(resource, params, security_result)
531
+ def self.execute_resource_handler(resource, params, session_context)
557
532
  if [1, -1].include?(resource.handler.arity)
558
533
  resource.handler.call(params)
559
534
  else
560
- resource.handler.call(params, security_result[:session_context])
535
+ resource.handler.call(params, session_context)
561
536
  end
562
537
  end
563
538
 
@@ -579,10 +554,32 @@ module VectorMCP
579
554
  context.result
580
555
  end
581
556
 
582
- private_class_method :handle_middleware_error, :create_tool_context, :find_tool!, :validate_tool_security!,
557
+ # Create middleware context for authentication hooks.
558
+ def self.create_auth_context(operation_type, operation_name, request, session, server)
559
+ VectorMCP::Middleware::Context.new(
560
+ operation_type: operation_type,
561
+ operation_name: operation_name,
562
+ params: request,
563
+ session: session,
564
+ server: server,
565
+ metadata: { start_time: Time.now }
566
+ )
567
+ end
568
+
569
+ # Run auth error hooks and re-raise the original error.
570
+ def self.handle_auth_error(error, context, server)
571
+ return raise error unless context
572
+
573
+ context.error = error
574
+ server.middleware_manager.execute_hooks(:on_auth_error, context)
575
+ raise error
576
+ end
577
+
578
+ private_class_method :handle_middleware_error, :create_tool_context, :find_tool!, :authenticate_request!,
583
579
  :execute_tool_handler, :build_tool_result, :handle_tool_error, :create_resource_context,
584
- :find_resource!, :validate_resource_security!, :execute_resource_handler,
585
- :process_resource_content, :handle_resource_error
580
+ :find_resource!, :authorize_action!, :execute_resource_handler,
581
+ :process_resource_content, :handle_resource_error, :create_auth_context,
582
+ :handle_auth_error
586
583
  end
587
584
  end
588
585
  end
@@ -231,29 +231,52 @@ module VectorMCP
231
231
  # base_directory: "/app/uploads"
232
232
  # )
233
233
  def file_to_mcp_image_content(file_path, validate: true, max_size: DEFAULT_MAX_SIZE, base_directory: nil)
234
- validate_path_safety!(file_path, base_directory) if base_directory
234
+ validated_path = validate_path_safety!(file_path, base_directory)
235
235
 
236
- raise ArgumentError, "Image file not found: #{file_path}" unless File.exist?(file_path)
236
+ raise ArgumentError, "Image file not found: #{validated_path}" unless File.exist?(validated_path)
237
237
 
238
- raise ArgumentError, "Image file not readable: #{file_path}" unless File.readable?(file_path)
238
+ raise ArgumentError, "Image file not readable: #{validated_path}" unless File.readable?(validated_path)
239
239
 
240
- binary_data = File.binread(file_path)
240
+ binary_data = File.binread(validated_path)
241
241
  to_mcp_image_content(binary_data, validate: validate, max_size: max_size)
242
242
  end
243
243
 
244
- # Validates that a file path does not escape the given base directory.
244
+ # Validates that a file path is safe to access.
245
+ #
246
+ # When +base_directory+ is provided, the resolved path must reside within it.
247
+ # When +base_directory+ is omitted, the path is still canonicalized and
248
+ # rejected if it contains path traversal sequences (+..+) that resolve
249
+ # outside the current working directory.
245
250
  #
246
251
  # @param file_path [String] The file path to validate.
247
- # @param base_directory [String] The base directory boundary.
248
- # @raise [ArgumentError] If the resolved path is outside base_directory.
252
+ # @param base_directory [String, nil] Optional base directory boundary.
253
+ # @return [String] The canonicalized, validated path.
254
+ # @raise [ArgumentError] If path traversal is detected.
249
255
  # @api private
250
256
  def validate_path_safety!(file_path, base_directory)
251
- resolved_base = File.expand_path(base_directory)
252
- resolved_path = File.expand_path(file_path, resolved_base)
257
+ if base_directory
258
+ resolved_base = File.expand_path(base_directory)
259
+ resolved_path = File.expand_path(file_path, resolved_base)
253
260
 
254
- return if resolved_path.start_with?("#{resolved_base}/") || resolved_path == resolved_base
261
+ unless resolved_path.start_with?("#{resolved_base}/") || resolved_path == resolved_base
262
+ raise ArgumentError, "Path traversal detected: resolved path is outside the allowed base directory"
263
+ end
264
+ else
265
+ resolved_path = File.expand_path(file_path)
266
+
267
+ # Reject paths that use traversal sequences to escape upward, even without
268
+ # an explicit base directory. This catches the common case of
269
+ # user-supplied input like "../../etc/passwd".
270
+ if file_path.to_s.include?("..")
271
+ canonical_base = File.expand_path(".")
272
+ unless resolved_path.start_with?("#{canonical_base}/") || resolved_path == canonical_base
273
+ raise ArgumentError,
274
+ "Path traversal detected: '#{file_path}' resolves outside the working directory"
275
+ end
276
+ end
277
+ end
255
278
 
256
- raise ArgumentError, "Path traversal detected: resolved path is outside the allowed base directory"
279
+ resolved_path
257
280
  end
258
281
 
259
282
  # Extracts image metadata from binary data.
@@ -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
@@ -147,11 +147,7 @@ module VectorMCP
147
147
  # @param context [VectorMCP::Middleware::Context] Execution context
148
148
  # @param new_params [Hash] New parameters to set
149
149
  def modify_params(context, new_params)
150
- if context.respond_to?(:params=)
151
- context.params = new_params
152
- else
153
- @logger.warn("Cannot modify immutable params in context")
154
- end
150
+ context.params = new_params
155
151
  end
156
152
 
157
153
  # Helper method to modify response result
@@ -18,7 +18,7 @@ module VectorMCP
18
18
  def initialize(options = {})
19
19
  @operation_type = options[:operation_type]
20
20
  @operation_name = options[:operation_name]
21
- @params = (options[:params] || {}).dup.freeze # Immutable copy
21
+ self.params = options[:params]
22
22
  @session = options[:session]
23
23
  @server = options[:server]
24
24
  @metadata = (options[:metadata] || {}).dup
@@ -27,6 +27,16 @@ module VectorMCP
27
27
  @skip_remaining_hooks = false
28
28
  end
29
29
 
30
+ # Replace request parameters for the current operation.
31
+ #
32
+ # @param value [Hash, nil] New request parameters.
33
+ # @return [Hash] The normalized parameter hash.
34
+ def params=(value)
35
+ raise ArgumentError, "params must be a Hash" unless value.nil? || value.is_a?(Hash)
36
+
37
+ @params = (value || {}).dup
38
+ end
39
+
30
40
  # Check if operation completed successfully
31
41
  # @return [Boolean] true if no error occurred
32
42
  def success?
@@ -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