vector_mcp 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -1
- data/README.md +39 -0
- data/lib/vector_mcp/definitions.rb +30 -0
- data/lib/vector_mcp/handlers/core.rb +32 -89
- data/lib/vector_mcp/invocation.rb +106 -0
- 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/request_context.rb +97 -14
- data/lib/vector_mcp/security/auth_manager.rb +15 -14
- 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 +0 -47
- data/lib/vector_mcp/security/session_context.rb +11 -27
- data/lib/vector_mcp/security/strategies/api_key.rb +7 -52
- data/lib/vector_mcp/security/strategies/custom.rb +15 -39
- data/lib/vector_mcp/security/strategies/jwt_token.rb +7 -17
- data/lib/vector_mcp/server/capabilities.rb +22 -26
- data/lib/vector_mcp/server/message_handling.rb +24 -17
- data/lib/vector_mcp/server/registry.rb +70 -120
- data/lib/vector_mcp/server.rb +53 -19
- data/lib/vector_mcp/session.rb +55 -27
- data/lib/vector_mcp/token_store.rb +80 -0
- data/lib/vector_mcp/transport/base_session_manager.rb +12 -38
- data/lib/vector_mcp/transport/http_stream/event_store.rb +14 -16
- data/lib/vector_mcp/transport/http_stream/session_manager.rb +20 -61
- data/lib/vector_mcp/transport/http_stream/stream_handler.rb +7 -7
- data/lib/vector_mcp/transport/http_stream.rb +95 -55
- data/lib/vector_mcp/util/token_sweeper.rb +74 -0
- data/lib/vector_mcp/version.rb +1 -1
- data/lib/vector_mcp.rb +11 -0
- metadata +6 -1
|
@@ -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
|
|
@@ -91,6 +91,25 @@ module VectorMCP
|
|
|
91
91
|
http_methods.include?(@method.upcase)
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
+
# Return a new context with the given attributes merged in. Nested hashes
|
|
95
|
+
# (headers, params, transport_metadata) are merged key-wise; scalar
|
|
96
|
+
# attributes are replaced.
|
|
97
|
+
#
|
|
98
|
+
# @param attributes [Hash] attributes to merge
|
|
99
|
+
# @return [RequestContext] a new, merged context
|
|
100
|
+
def merge(attributes)
|
|
101
|
+
current = to_h
|
|
102
|
+
merged = current.dup
|
|
103
|
+
attributes.each do |key, value|
|
|
104
|
+
merged[key] = if value.is_a?(Hash) && current[key].is_a?(Hash)
|
|
105
|
+
current[key].merge(value)
|
|
106
|
+
else
|
|
107
|
+
value
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
RequestContext.new(**merged)
|
|
111
|
+
end
|
|
112
|
+
|
|
94
113
|
# Create a minimal request context for non-HTTP transports.
|
|
95
114
|
# This is useful for non-HTTP transports or testing contexts.
|
|
96
115
|
#
|
|
@@ -106,6 +125,73 @@ module VectorMCP
|
|
|
106
125
|
)
|
|
107
126
|
end
|
|
108
127
|
|
|
128
|
+
# Coerce an arbitrary request representation into a RequestContext.
|
|
129
|
+
# This is the single boundary where legacy request shapes (raw Rack
|
|
130
|
+
# environments, `{headers:, params:}` hashes with symbol or string keys)
|
|
131
|
+
# are converted; everything downstream of authentication trusts this type.
|
|
132
|
+
#
|
|
133
|
+
# @param request [RequestContext, Hash] the request to coerce
|
|
134
|
+
# @param transport_type [String] transport identifier used when building from a Rack env
|
|
135
|
+
# @return [RequestContext]
|
|
136
|
+
# @raise [ArgumentError] if the input cannot be coerced
|
|
137
|
+
def self.coerce(request, transport_type = "http")
|
|
138
|
+
case request
|
|
139
|
+
when RequestContext
|
|
140
|
+
request
|
|
141
|
+
when Hash
|
|
142
|
+
return from_rack_env(request, transport_type) if request.key?("REQUEST_METHOD")
|
|
143
|
+
|
|
144
|
+
from_normalized_hash(request)
|
|
145
|
+
else
|
|
146
|
+
raise ArgumentError, "Cannot coerce #{request.class} into a VectorMCP::RequestContext"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Build a context from a legacy `{headers:, params:, ...}` hash with either
|
|
151
|
+
# symbol or string keys.
|
|
152
|
+
# @api private
|
|
153
|
+
def self.from_normalized_hash(hash)
|
|
154
|
+
fetch = ->(key) { hash[key] || hash[key.to_s] }
|
|
155
|
+
new(
|
|
156
|
+
headers: fetch.call(:headers) || {},
|
|
157
|
+
params: fetch.call(:params) || {},
|
|
158
|
+
method: fetch.call(:method),
|
|
159
|
+
path: fetch.call(:path),
|
|
160
|
+
transport_metadata: fetch.call(:transport_metadata) || {}
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
private_class_method :from_normalized_hash
|
|
164
|
+
|
|
165
|
+
# Hash-style read access for compatibility with custom authentication
|
|
166
|
+
# handlers written against the legacy `{headers:, params:}` request hash.
|
|
167
|
+
#
|
|
168
|
+
# @param key [String, Symbol] one of :headers, :params, :method, :path, :transport_metadata
|
|
169
|
+
# @return [Object, nil]
|
|
170
|
+
def [](key)
|
|
171
|
+
hash_view[key.to_sym]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# @param key [String, Symbol] the top-level key to check
|
|
175
|
+
# @return [Boolean]
|
|
176
|
+
def key?(key)
|
|
177
|
+
hash_view.key?(key.to_sym)
|
|
178
|
+
end
|
|
179
|
+
alias has_key? key?
|
|
180
|
+
alias include? key?
|
|
181
|
+
alias member? key?
|
|
182
|
+
|
|
183
|
+
# Dig into the context like a nested hash, e.g. `context.dig(:headers, "X-API-Key")`.
|
|
184
|
+
#
|
|
185
|
+
# @param key [String, Symbol] the top-level key
|
|
186
|
+
# @param rest [Array] further keys passed to the nested value's #dig
|
|
187
|
+
# @return [Object, nil]
|
|
188
|
+
def dig(key, *rest)
|
|
189
|
+
value = self[key]
|
|
190
|
+
return value if rest.empty?
|
|
191
|
+
|
|
192
|
+
value&.dig(*rest)
|
|
193
|
+
end
|
|
194
|
+
|
|
109
195
|
# Create a request context from a Rack environment.
|
|
110
196
|
# This is a convenience method for HTTP-based transports.
|
|
111
197
|
#
|
|
@@ -149,25 +235,22 @@ module VectorMCP
|
|
|
149
235
|
|
|
150
236
|
private
|
|
151
237
|
|
|
152
|
-
#
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
# @return [Hash] Normalized headers hash
|
|
156
|
-
def normalize_headers(headers)
|
|
157
|
-
return {} unless headers.is_a?(Hash)
|
|
158
|
-
|
|
159
|
-
headers.transform_keys(&:to_s).transform_values { |v| v.nil? ? "" : v.to_s }
|
|
238
|
+
# Memoized nested-hash view backing the hash-style read accessors.
|
|
239
|
+
def hash_view
|
|
240
|
+
@hash_view ||= to_h.freeze
|
|
160
241
|
end
|
|
161
242
|
|
|
162
|
-
# Normalize
|
|
243
|
+
# Normalize a headers/params hash to string keys and string values.
|
|
163
244
|
#
|
|
164
|
-
# @param
|
|
165
|
-
# @return [Hash] Normalized
|
|
166
|
-
def
|
|
167
|
-
return {} unless
|
|
245
|
+
# @param hash [Hash] Raw hash
|
|
246
|
+
# @return [Hash] Normalized hash
|
|
247
|
+
def normalize_string_map(hash)
|
|
248
|
+
return {} unless hash.is_a?(Hash)
|
|
168
249
|
|
|
169
|
-
|
|
250
|
+
hash.transform_keys(&:to_s).transform_values { |v| v.nil? ? "" : v.to_s }
|
|
170
251
|
end
|
|
252
|
+
alias normalize_headers normalize_string_map
|
|
253
|
+
alias normalize_params normalize_string_map
|
|
171
254
|
|
|
172
255
|
# Normalize transport metadata to ensure consistent format.
|
|
173
256
|
#
|
|
@@ -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
|
|
@@ -40,27 +42,26 @@ module VectorMCP
|
|
|
40
42
|
end
|
|
41
43
|
|
|
42
44
|
# Authenticate a request using the specified or default strategy
|
|
43
|
-
# @param request [Hash] the request
|
|
45
|
+
# @param request [VectorMCP::RequestContext, Hash] the request. Legacy hash
|
|
46
|
+
# shapes (raw Rack env, {headers:, params:}) are coerced here — the single
|
|
47
|
+
# normalization boundary for the authentication pipeline.
|
|
44
48
|
# @param strategy [Symbol] optional strategy override
|
|
45
|
-
# @return [
|
|
49
|
+
# @return [AuthResult] the authentication outcome
|
|
46
50
|
def authenticate(request, strategy: nil)
|
|
47
|
-
return
|
|
51
|
+
return AuthResult.passthrough unless @enabled
|
|
48
52
|
|
|
49
53
|
strategy_name = strategy || @default_strategy
|
|
50
54
|
auth_strategy = @strategies[strategy_name]
|
|
55
|
+
return AuthResult.failure unless auth_strategy
|
|
51
56
|
|
|
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}" }
|
|
57
|
+
result = auth_strategy.authenticate(VectorMCP::RequestContext.coerce(request))
|
|
58
|
+
if result == false
|
|
59
|
+
AuthResult.failure
|
|
60
|
+
else
|
|
61
|
+
AuthResult.success(user: result, strategy: strategy_name.to_s)
|
|
63
62
|
end
|
|
63
|
+
rescue StandardError
|
|
64
|
+
AuthResult.failure
|
|
64
65
|
end
|
|
65
66
|
|
|
66
67
|
# Check if authentication is required
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VectorMCP
|
|
4
|
+
module Security
|
|
5
|
+
# Value object representing the outcome of an authentication attempt.
|
|
6
|
+
# Replaces the unstructured Hash that previously flowed through the auth pipeline.
|
|
7
|
+
class AuthResult
|
|
8
|
+
attr_reader :user, :strategy, :authenticated_at
|
|
9
|
+
|
|
10
|
+
def initialize(authenticated:, user: nil, strategy: nil, authenticated_at: nil)
|
|
11
|
+
@authenticated = authenticated
|
|
12
|
+
@user = user
|
|
13
|
+
@strategy = strategy
|
|
14
|
+
@authenticated_at = authenticated_at || (Time.now if authenticated)
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def authenticated? = @authenticated
|
|
19
|
+
|
|
20
|
+
def self.success(user:, strategy:, authenticated_at: Time.now)
|
|
21
|
+
new(authenticated: true, user: user, strategy: strategy, authenticated_at: authenticated_at)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.failure
|
|
25
|
+
new(authenticated: false)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.passthrough
|
|
29
|
+
new(authenticated: true)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -10,6 +10,7 @@ module VectorMCP
|
|
|
10
10
|
def initialize
|
|
11
11
|
@policies = {}
|
|
12
12
|
@enabled = false
|
|
13
|
+
@logger = VectorMCP.logger_for("authorization")
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
# Enable authorization system
|
|
@@ -45,17 +46,12 @@ module VectorMCP
|
|
|
45
46
|
|
|
46
47
|
resource_type = determine_resource_type(resource)
|
|
47
48
|
policy = @policies[resource_type]
|
|
48
|
-
|
|
49
|
-
# If no policy is defined, allow access (opt-in authorization)
|
|
50
49
|
return true unless policy
|
|
51
50
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
# Log error but deny access for safety
|
|
57
|
-
false
|
|
58
|
-
end
|
|
51
|
+
!!policy.call(user, action, resource)
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
@logger.error("Authorization policy error for #{resource_type}: #{e.message}")
|
|
54
|
+
false
|
|
59
55
|
end
|
|
60
56
|
|
|
61
57
|
# Check if authorization is required
|
|
@@ -73,25 +73,6 @@ module VectorMCP
|
|
|
73
73
|
}
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
-
# Create a request object from different transport formats
|
|
77
|
-
# @param transport_request [Object] the transport-specific request
|
|
78
|
-
# @return [Hash] normalized request object
|
|
79
|
-
def normalize_request(transport_request)
|
|
80
|
-
case transport_request
|
|
81
|
-
when Hash
|
|
82
|
-
# Check if it's a Rack environment (has REQUEST_METHOD key)
|
|
83
|
-
if transport_request.key?("REQUEST_METHOD")
|
|
84
|
-
extract_from_rack_env(transport_request)
|
|
85
|
-
else
|
|
86
|
-
# Already normalized
|
|
87
|
-
transport_request
|
|
88
|
-
end
|
|
89
|
-
else
|
|
90
|
-
# Extract from transport-specific request (e.g., custom objects)
|
|
91
|
-
extract_request_data(transport_request)
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
|
|
95
76
|
# Check if security is enabled
|
|
96
77
|
# @return [Boolean] true if any security features are enabled
|
|
97
78
|
def security_enabled?
|
|
@@ -113,34 +94,6 @@ module VectorMCP
|
|
|
113
94
|
}
|
|
114
95
|
}
|
|
115
96
|
end
|
|
116
|
-
|
|
117
|
-
private
|
|
118
|
-
|
|
119
|
-
# Extract request data from transport-specific formats
|
|
120
|
-
# @param transport_request [Object] the transport request
|
|
121
|
-
# @return [Hash] extracted request data
|
|
122
|
-
def extract_request_data(transport_request)
|
|
123
|
-
# Handle Rack environment (for HTTP transports)
|
|
124
|
-
if transport_request.respond_to?(:[]) && transport_request["REQUEST_METHOD"]
|
|
125
|
-
extract_from_rack_env(transport_request)
|
|
126
|
-
else
|
|
127
|
-
# Default fallback for non-HTTP request formats
|
|
128
|
-
{ headers: {}, params: {} }
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
# Extract data from Rack environment
|
|
133
|
-
# @param env [Hash] the Rack environment
|
|
134
|
-
# @return [Hash] extracted request data
|
|
135
|
-
def extract_from_rack_env(env)
|
|
136
|
-
{
|
|
137
|
-
headers: VectorMCP::Util.extract_headers_from_rack_env(env),
|
|
138
|
-
params: VectorMCP::Util.extract_params_from_rack_env(env),
|
|
139
|
-
method: env["REQUEST_METHOD"],
|
|
140
|
-
path: env["PATH_INFO"],
|
|
141
|
-
rack_env: env
|
|
142
|
-
}
|
|
143
|
-
end
|
|
144
97
|
end
|
|
145
98
|
end
|
|
146
99
|
end
|
|
@@ -113,34 +113,18 @@ module VectorMCP
|
|
|
113
113
|
new(authenticated: false)
|
|
114
114
|
end
|
|
115
115
|
|
|
116
|
-
# Create an authenticated session context from
|
|
117
|
-
# @param auth_result [
|
|
118
|
-
# @return [SessionContext] an authenticated session
|
|
116
|
+
# Create an authenticated session context from an AuthResult
|
|
117
|
+
# @param auth_result [VectorMCP::Security::AuthResult] the authentication outcome
|
|
118
|
+
# @return [SessionContext] an authenticated or anonymous session
|
|
119
119
|
def self.from_auth_result(auth_result)
|
|
120
|
-
return anonymous unless auth_result&.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
authenticated: true,
|
|
129
|
-
auth_strategy: "custom",
|
|
130
|
-
authenticated_at: Time.now
|
|
131
|
-
)
|
|
132
|
-
else
|
|
133
|
-
# Extract strategy and authenticated_at only if user_data is a Hash
|
|
134
|
-
strategy = user_data.is_a?(Hash) ? user_data[:strategy] : nil
|
|
135
|
-
auth_time = user_data.is_a?(Hash) ? user_data[:authenticated_at] : nil
|
|
136
|
-
|
|
137
|
-
new(
|
|
138
|
-
user: user_data,
|
|
139
|
-
authenticated: true,
|
|
140
|
-
auth_strategy: strategy,
|
|
141
|
-
authenticated_at: auth_time
|
|
142
|
-
)
|
|
143
|
-
end
|
|
120
|
+
return anonymous unless auth_result&.authenticated?
|
|
121
|
+
|
|
122
|
+
new(
|
|
123
|
+
user: auth_result.user,
|
|
124
|
+
authenticated: true,
|
|
125
|
+
auth_strategy: auth_result.strategy,
|
|
126
|
+
authenticated_at: auth_result.authenticated_at
|
|
127
|
+
)
|
|
144
128
|
end
|
|
145
129
|
end
|
|
146
130
|
end
|