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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +82 -0
- data/README.md +147 -337
- data/lib/vector_mcp/definitions.rb +30 -0
- data/lib/vector_mcp/handlers/core.rb +78 -81
- data/lib/vector_mcp/image_util.rb +34 -11
- data/lib/vector_mcp/middleware/anonymizer.rb +186 -0
- data/lib/vector_mcp/middleware/base.rb +1 -5
- data/lib/vector_mcp/middleware/context.rb +11 -1
- data/lib/vector_mcp/middleware/hook.rb +7 -24
- data/lib/vector_mcp/middleware.rb +26 -9
- data/lib/vector_mcp/rails/tool.rb +85 -0
- data/lib/vector_mcp/request_context.rb +1 -1
- data/lib/vector_mcp/security/auth_manager.rb +12 -13
- data/lib/vector_mcp/security/auth_result.rb +33 -0
- data/lib/vector_mcp/security/authorization.rb +5 -9
- data/lib/vector_mcp/security/middleware.rb +2 -2
- data/lib/vector_mcp/security/session_context.rb +11 -27
- data/lib/vector_mcp/security/strategies/api_key.rb +1 -5
- data/lib/vector_mcp/security/strategies/custom.rb +10 -37
- data/lib/vector_mcp/security/strategies/jwt_token.rb +1 -10
- data/lib/vector_mcp/server/capabilities.rb +22 -32
- data/lib/vector_mcp/server/message_handling.rb +21 -14
- data/lib/vector_mcp/server/registry.rb +102 -120
- data/lib/vector_mcp/server.rb +98 -57
- data/lib/vector_mcp/session.rb +5 -3
- data/lib/vector_mcp/token_store.rb +80 -0
- data/lib/vector_mcp/tool.rb +221 -0
- data/lib/vector_mcp/transport/base_session_manager.rb +1 -17
- data/lib/vector_mcp/transport/http_stream/event_store.rb +29 -17
- data/lib/vector_mcp/transport/http_stream/session_manager.rb +41 -36
- data/lib/vector_mcp/transport/http_stream/stream_handler.rb +132 -47
- data/lib/vector_mcp/transport/http_stream.rb +242 -124
- data/lib/vector_mcp/util/token_sweeper.rb +74 -0
- data/lib/vector_mcp/version.rb +1 -1
- data/lib/vector_mcp.rb +8 -8
- metadata +8 -10
- data/lib/vector_mcp/transport/sse/client_connection.rb +0 -113
- data/lib/vector_mcp/transport/sse/message_handler.rb +0 -166
- data/lib/vector_mcp/transport/sse/puma_config.rb +0 -77
- data/lib/vector_mcp/transport/sse/stream_manager.rb +0 -92
- data/lib/vector_mcp/transport/sse.rb +0 -377
- data/lib/vector_mcp/transport/sse_session_manager.rb +0 -188
- data/lib/vector_mcp/transport/stdio.rb +0 -473
- 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
|
-
|
|
64
|
-
|
|
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,
|
|
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
|
-
|
|
115
|
+
authorize_action!(session_context, :read, resource, server)
|
|
110
116
|
|
|
111
|
-
content_raw = execute_resource_handler(resource, params,
|
|
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 =
|
|
201
|
-
|
|
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.
|
|
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 :
|
|
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.
|
|
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 :
|
|
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.
|
|
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 :
|
|
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
|
-
#
|
|
495
|
-
def self.
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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,
|
|
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
|
-
#
|
|
549
|
-
def self.
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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!, :
|
|
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)
|
|
234
|
+
validated_path = validate_path_safety!(file_path, base_directory)
|
|
235
235
|
|
|
236
|
-
raise ArgumentError, "Image file not found: #{
|
|
236
|
+
raise ArgumentError, "Image file not found: #{validated_path}" unless File.exist?(validated_path)
|
|
237
237
|
|
|
238
|
-
raise ArgumentError, "Image file not readable: #{
|
|
238
|
+
raise ArgumentError, "Image file not readable: #{validated_path}" unless File.readable?(validated_path)
|
|
239
239
|
|
|
240
|
-
binary_data = File.binread(
|
|
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
|
|
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]
|
|
248
|
-
# @
|
|
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
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
return true if transport_or_auth_hook?(operation_prefix)
|
|
80
|
+
return true if @operation_type.nil?
|
|
79
81
|
|
|
80
|
-
|
|
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
|
-
#
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|