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 +4 -4
- data/CHANGELOG.md +24 -1
- data/README.md +20 -0
- data/lib/vector_mcp/definitions.rb +30 -0
- data/lib/vector_mcp/handlers/core.rb +18 -76
- data/lib/vector_mcp/middleware/anonymizer.rb +186 -0
- data/lib/vector_mcp/middleware/hook.rb +7 -24
- data/lib/vector_mcp/middleware.rb +26 -9
- 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/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 -26
- data/lib/vector_mcp/server/message_handling.rb +21 -14
- data/lib/vector_mcp/server/registry.rb +70 -120
- data/lib/vector_mcp/server.rb +53 -19
- data/lib/vector_mcp/token_store.rb +80 -0
- data/lib/vector_mcp/transport/http_stream/event_store.rb +14 -16
- data/lib/vector_mcp/transport/http_stream/session_manager.rb +8 -26
- data/lib/vector_mcp/transport/http_stream.rb +81 -42
- data/lib/vector_mcp/util/token_sweeper.rb +74 -0
- data/lib/vector_mcp/version.rb +1 -1
- data/lib/vector_mcp.rb +2 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8125dfaaa8d0965448eac78401a013a1f5e41292f29141a72c44fca6dbb96cba
|
|
4
|
+
data.tar.gz: cef6523bbd0c43e969102f5776f6bcb5f2e6a9e10507b4faee1839258fe7f39f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6dafbddbeeb279f3232ad45c979b94b92266b1c05060f057dd747bc51ad6cc290da9c30886c9d72cee0445554445cf78e963efd354481974b7b875a5c98e0d21
|
|
7
|
+
data.tar.gz: 68003e44e084dc40cef4ae04add212901c65056d5432a18aad00550fc8055537812693b915b1f6b00c7e409178ea0801d54f3c9fc3a350c3cca5c65f9afcf700
|
data/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,27 @@
|
|
|
1
|
-
## [
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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 :
|
|
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.
|
|
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 :
|
|
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.
|
|
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 :
|
|
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
|
-
#
|
|
503
|
-
def self.
|
|
504
|
-
request =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
523
|
+
# Check authorization and raise ForbiddenError if denied
|
|
576
524
|
def self.authorize_action!(session_context, action, resource, server)
|
|
577
|
-
|
|
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!, :
|
|
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
|
-
|
|
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
|
|
@@ -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 [
|
|
47
|
+
# @return [AuthResult] the authentication outcome
|
|
46
48
|
def authenticate(request, strategy: nil)
|
|
47
|
-
return
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|