vector_mcp 0.6.0 → 0.7.1
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 +23 -0
- data/README.md +8 -1
- data/lib/vector_mcp/errors.rb +28 -0
- data/lib/vector_mcp/handlers/core.rb +66 -97
- data/lib/vector_mcp/invocation.rb +16 -4
- data/lib/vector_mcp/security/authentication_rate_limiter.rb +187 -0
- data/lib/vector_mcp/security/middleware.rb +29 -2
- data/lib/vector_mcp/security/request_authenticator.rb +97 -0
- data/lib/vector_mcp/server/capabilities.rb +0 -7
- data/lib/vector_mcp/server/message_handling.rb +22 -10
- data/lib/vector_mcp/server.rb +25 -2
- data/lib/vector_mcp/session.rb +5 -73
- data/lib/vector_mcp/transport/http_stream/stream_handler.rb +2 -11
- data/lib/vector_mcp/transport/http_stream.rb +76 -9
- data/lib/vector_mcp/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b00aa3c214f8fcdd9906a9c349b8b871c0f3ab8fb9463d198e007d28783fe43d
|
|
4
|
+
data.tar.gz: 6053bad04ea796b29312812a4ac6e1c0dcf1ba2a9bc292cfd902e5e3793d78cb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b5eec0da560953e468c81b7af75dd5f3274dce64617e287f085a70327c83ff2085ed6a30012cf9fbd27638c1e415ce8b40e77fb6f61b20a779b5b9bd48f1c47b
|
|
7
|
+
data.tar.gz: ff5cd45eb8237ccddc953811fafa1912c0dbd4b0a0afd03bd373107614e1552a675e2e710d43aefd4f8dd693e46c3e89fd16d964917012e5a6379ff813ff2948
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
## [0.7.1] – 2026-08-16
|
|
2
|
+
|
|
3
|
+
### Security
|
|
4
|
+
|
|
5
|
+
* Authentication now covers `prompts/get` and all tool, resource, prompt, and root catalog list requests when authentication is enabled.
|
|
6
|
+
* Prompt/root authorization policies are enforced; list policies filter denied catalog entries without disclosing them.
|
|
7
|
+
* HTTP Stream POST bodies are capped at 16 MiB by default, with a configurable `max_body_bytes:` limit and `413 Payload Too Large` responses.
|
|
8
|
+
* Authentication is enforced at the shared dispatcher for every registered request and notification handler except the explicit public handshake methods (`initialize`, `ping`, and `initialized`). HTTP GET/DELETE operations also require credentials outside OAuth resource-server mode.
|
|
9
|
+
* The unsupported `prompts/subscribe` placeholder and its unbounded retained-session array were removed.
|
|
10
|
+
* Authentication can now enable a bounded, thread-safe failure limiter with `rate_limit:`. It tracks client IPs and credential fingerprints, emits structured security events, resets matching counters after success, and returns `429`/`Retry-After` after repeated failures.
|
|
11
|
+
|
|
12
|
+
## [0.7.0] – 2026-08-15
|
|
13
|
+
|
|
14
|
+
### Removed (deprecated in 0.6.0)
|
|
15
|
+
|
|
16
|
+
* **The request-scoped API on `Session`**: `request_context=`, `update_request_context`, `request_headers?`, `request_params?`, `request_header`, `request_param`. Request-scoped data lives on the per-request `VectorMCP::Invocation` passed to handlers; sessions keep only their creation-time context.
|
|
17
|
+
* **`Server#handle_message`'s third `session_id` argument** — the signature is now `handle_message(message, session)`.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
* **Docs and examples teach the invocation-based handler signatures** (`do |args, invocation|`); handlers written against the legacy security-context argument continue to work via the invocation's forwarded auth API.
|
|
22
|
+
* **Stream handler drops the legacy session-id connection keying** — streaming connections are tracked solely by stream ID (per-stream, resumable); the dead session-id fallback lookups are removed.
|
|
23
|
+
|
|
1
24
|
## [0.6.0] – 2026-07-19
|
|
2
25
|
|
|
3
26
|
### Added
|
data/README.md
CHANGED
|
@@ -188,7 +188,8 @@ VectorMCP keeps security opt-in, but the primitives are built in:
|
|
|
188
188
|
```ruby
|
|
189
189
|
server.enable_authentication!(
|
|
190
190
|
strategy: :api_key,
|
|
191
|
-
keys: ["
|
|
191
|
+
keys: [ENV.fetch("MCP_API_KEY")],
|
|
192
|
+
rate_limit: { max_attempts: 10, window_seconds: 60 }
|
|
192
193
|
)
|
|
193
194
|
|
|
194
195
|
server.enable_authorization! do
|
|
@@ -208,6 +209,10 @@ server.enable_authentication!(strategy: :custom) do |request|
|
|
|
208
209
|
end
|
|
209
210
|
```
|
|
210
211
|
|
|
212
|
+
When authentication is enabled, VectorMCP applies it centrally to built-in and custom request/notification handlers. Only `initialize`, `ping`, and the `initialized` notification are public; HTTP GET streams and DELETE session requests also require credentials.
|
|
213
|
+
|
|
214
|
+
For public deployments, enable authentication failure limiting with `rate_limit: true` (10 attempts per 60 seconds by default), or provide `max_attempts`, `window_seconds`, and `max_entries`. Repeated failures are tracked by client IP and a one-way credential fingerprint; blocked requests return HTTP `429`, JSON-RPC `-32029`, and `Retry-After`. The limiter is in-process, so multi-process or distributed deployments should also enforce a shared limit at the proxy or gateway. Generate API keys with at least 256 bits of entropy—for example, `ruby -rsecurerandom -e 'puts SecureRandom.hex(32)'`—and load them from a secret manager or environment variable.
|
|
215
|
+
|
|
211
216
|
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.
|
|
212
217
|
|
|
213
218
|
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`.
|
|
@@ -237,6 +242,8 @@ anonymizer.install_on(server)
|
|
|
237
242
|
- `DELETE /mcp` terminates the session
|
|
238
243
|
- The server advertises MCP protocol `2025-11-25` and accepts `2025-03-26` and `2024-11-05` headers for compatibility
|
|
239
244
|
- Default allowed origins are restricted to localhost and loopback addresses
|
|
245
|
+
- POST bodies are capped at 16 MiB by default; configure `max_body_bytes:` on `run` or `rack_app` when needed
|
|
246
|
+
- For mounted Rack apps, configure the same limit in the fronting web server or reverse proxy so requests are rejected before Rack buffering
|
|
240
247
|
|
|
241
248
|
Initialize a session with curl:
|
|
242
249
|
|
data/lib/vector_mcp/errors.rb
CHANGED
|
@@ -4,6 +4,16 @@ module VectorMCP
|
|
|
4
4
|
# Base error class for all VectorMCP specific errors.
|
|
5
5
|
class Error < StandardError; end
|
|
6
6
|
|
|
7
|
+
# Raised when an HTTP request body exceeds the transport's configured limit.
|
|
8
|
+
class PayloadTooLargeError < Error
|
|
9
|
+
attr_reader :max_bytes
|
|
10
|
+
|
|
11
|
+
def initialize(max_bytes)
|
|
12
|
+
@max_bytes = max_bytes
|
|
13
|
+
super("Request body exceeds the maximum allowed size of #{max_bytes} bytes")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
7
17
|
# Base class for **all** JSON-RPC 2.0 errors that the VectorMCP library can
|
|
8
18
|
# emit. It mirrors the structure defined by the JSON-RPC spec and adds a
|
|
9
19
|
# flexible `details` field that implementers may use to attach structured,
|
|
@@ -187,6 +197,24 @@ module VectorMCP
|
|
|
187
197
|
end
|
|
188
198
|
end
|
|
189
199
|
|
|
200
|
+
# Represents an authentication attempt blocked by the configured limiter (-32029).
|
|
201
|
+
class RateLimitExceededError < ServerError
|
|
202
|
+
attr_reader :retry_after
|
|
203
|
+
|
|
204
|
+
# @param message [String] The error message.
|
|
205
|
+
# @param retry_after [Integer] Seconds until the caller should retry.
|
|
206
|
+
# @param request_id [String, Integer, nil] The ID of the originating request.
|
|
207
|
+
def initialize(message = "Too many authentication attempts", retry_after:, request_id: nil)
|
|
208
|
+
@retry_after = retry_after
|
|
209
|
+
super(
|
|
210
|
+
message,
|
|
211
|
+
code: -32_029,
|
|
212
|
+
details: { retry_after: retry_after },
|
|
213
|
+
request_id: request_id
|
|
214
|
+
)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
190
218
|
# Represents an authorization failed error (-32403).
|
|
191
219
|
# Indicates the authenticated user does not have permission to perform the requested action.
|
|
192
220
|
class ForbiddenError < ProtocolError
|
|
@@ -4,6 +4,7 @@ require "json"
|
|
|
4
4
|
require "uri"
|
|
5
5
|
require "json-schema"
|
|
6
6
|
require_relative "../middleware"
|
|
7
|
+
require_relative "../security/request_authenticator"
|
|
7
8
|
|
|
8
9
|
module VectorMCP
|
|
9
10
|
module Handlers
|
|
@@ -28,13 +29,16 @@ module VectorMCP
|
|
|
28
29
|
# Handles the `tools/list` request.
|
|
29
30
|
#
|
|
30
31
|
# @param _params [Hash] The request parameters (ignored).
|
|
31
|
-
# @param
|
|
32
|
+
# @param session [VectorMCP::Session] The current session.
|
|
32
33
|
# @param server [VectorMCP::Server] The server instance.
|
|
33
34
|
# @return [Hash] A hash containing an array of tool definitions.
|
|
34
35
|
# Example: `{ tools: [ { name: "my_tool", ... } ] }`
|
|
35
|
-
def self.list_tools(_params,
|
|
36
|
+
def self.list_tools(_params, session, server)
|
|
37
|
+
session_context = authenticate_request!(session, server, operation_type: :tool_list, operation_name: "tools/list")
|
|
38
|
+
tools = filter_authorized_items(server.tools.values, session_context, server)
|
|
39
|
+
|
|
36
40
|
{
|
|
37
|
-
tools:
|
|
41
|
+
tools: tools.map(&:as_mcp_definition)
|
|
38
42
|
}
|
|
39
43
|
end
|
|
40
44
|
|
|
@@ -79,13 +83,16 @@ module VectorMCP
|
|
|
79
83
|
# Handles the `resources/list` request.
|
|
80
84
|
#
|
|
81
85
|
# @param _params [Hash] The request parameters (ignored).
|
|
82
|
-
# @param
|
|
86
|
+
# @param session [VectorMCP::Session] The current session.
|
|
83
87
|
# @param server [VectorMCP::Server] The server instance.
|
|
84
88
|
# @return [Hash] A hash containing an array of resource definitions.
|
|
85
89
|
# Example: `{ resources: [ { uri: "memory://data", name: "My Data", ... } ] }`
|
|
86
|
-
def self.list_resources(_params,
|
|
90
|
+
def self.list_resources(_params, session, server)
|
|
91
|
+
session_context = authenticate_request!(session, server, operation_type: :resource_list, operation_name: "resources/list")
|
|
92
|
+
resources = filter_authorized_items(server.resources.values, session_context, server)
|
|
93
|
+
|
|
87
94
|
{
|
|
88
|
-
resources:
|
|
95
|
+
resources: resources.map(&:as_mcp_definition)
|
|
89
96
|
}
|
|
90
97
|
end
|
|
91
98
|
|
|
@@ -129,14 +136,17 @@ module VectorMCP
|
|
|
129
136
|
# If the server supports dynamic prompt lists, this clears the `listChanged` flag.
|
|
130
137
|
#
|
|
131
138
|
# @param _params [Hash] The request parameters (ignored).
|
|
132
|
-
# @param
|
|
139
|
+
# @param session [VectorMCP::Session] The current session.
|
|
133
140
|
# @param server [VectorMCP::Server] The server instance.
|
|
134
141
|
# @return [Hash] A hash containing an array of prompt definitions.
|
|
135
142
|
# Example: `{ prompts: [ { name: "my_prompt", ... } ] }`
|
|
136
|
-
def self.list_prompts(_params,
|
|
143
|
+
def self.list_prompts(_params, session, server)
|
|
144
|
+
session_context = authenticate_request!(session, server, operation_type: :prompt_list, operation_name: "prompts/list")
|
|
145
|
+
prompts = filter_authorized_items(server.prompts.values, session_context, server)
|
|
146
|
+
|
|
137
147
|
# Once the list is supplied, clear the listChanged flag
|
|
138
148
|
result = {
|
|
139
|
-
prompts:
|
|
149
|
+
prompts: prompts.map(&:as_mcp_definition)
|
|
140
150
|
}
|
|
141
151
|
server.clear_prompts_list_changed if server.respond_to?(:clear_prompts_list_changed)
|
|
142
152
|
result
|
|
@@ -146,38 +156,28 @@ module VectorMCP
|
|
|
146
156
|
# Returns the list of available roots and clears the `listChanged` flag.
|
|
147
157
|
#
|
|
148
158
|
# @param _params [Hash] The request parameters (ignored).
|
|
149
|
-
# @param
|
|
159
|
+
# @param session [VectorMCP::Session] The current session.
|
|
150
160
|
# @param server [VectorMCP::Server] The server instance.
|
|
151
161
|
# @return [Hash] A hash containing an array of root definitions.
|
|
152
162
|
# Example: `{ roots: [ { uri: "file:///path/to/dir", name: "My Project" } ] }`
|
|
153
|
-
def self.list_roots(_params,
|
|
163
|
+
def self.list_roots(_params, session, server)
|
|
164
|
+
session_context = authenticate_request!(session, server, operation_type: :root_list, operation_name: "roots/list")
|
|
165
|
+
roots = filter_authorized_items(server.roots.values, session_context, server)
|
|
166
|
+
|
|
154
167
|
# Once the list is supplied, clear the listChanged flag
|
|
155
168
|
result = {
|
|
156
|
-
roots:
|
|
169
|
+
roots: roots.map(&:as_mcp_definition)
|
|
157
170
|
}
|
|
158
171
|
server.clear_roots_list_changed if server.respond_to?(:clear_roots_list_changed)
|
|
159
172
|
result
|
|
160
173
|
end
|
|
161
174
|
|
|
162
|
-
# Handles the `prompts/subscribe` request (placeholder).
|
|
163
|
-
# This implementation is a simple acknowledgement.
|
|
164
|
-
#
|
|
165
|
-
# @param _params [Hash] The request parameters (ignored).
|
|
166
|
-
# @param session [VectorMCP::Session] The current session.
|
|
167
|
-
# @param server [VectorMCP::Server] The server instance.
|
|
168
|
-
# @return [Hash] An empty hash.
|
|
169
|
-
def self.subscribe_prompts(_params, session, server)
|
|
170
|
-
# Use private helper via send to avoid making it public
|
|
171
|
-
server.send(:subscribe_prompts, session) if server.respond_to?(:send)
|
|
172
|
-
{}
|
|
173
|
-
end
|
|
174
|
-
|
|
175
175
|
# Handles the `prompts/get` request.
|
|
176
176
|
# Validates arguments and the structure of the prompt handler's response.
|
|
177
177
|
#
|
|
178
178
|
# @param params [Hash] The request parameters.
|
|
179
179
|
# Expected keys: "name" (String), "arguments" (Hash, optional).
|
|
180
|
-
# @param
|
|
180
|
+
# @param session [VectorMCP::Session] The current session.
|
|
181
181
|
# @param server [VectorMCP::Server] The server instance.
|
|
182
182
|
# @return [Hash] The result from the prompt's handler, which should conform to MCP's GetPromptResult.
|
|
183
183
|
# @raise [VectorMCP::NotFoundError] if the prompt name is not found.
|
|
@@ -196,30 +196,8 @@ module VectorMCP
|
|
|
196
196
|
metadata: { start_time: Time.now }
|
|
197
197
|
)
|
|
198
198
|
|
|
199
|
-
# Execute before_prompt_get hooks
|
|
200
|
-
context = server.middleware_manager.execute_hooks(:before_prompt_get, context)
|
|
201
|
-
return handle_middleware_error(context) if context.error?
|
|
202
|
-
|
|
203
199
|
begin
|
|
204
|
-
|
|
205
|
-
prompt_name = context_params["name"] || prompt_name
|
|
206
|
-
prompt = fetch_prompt(prompt_name, server)
|
|
207
|
-
|
|
208
|
-
arguments = context_params["arguments"] || {}
|
|
209
|
-
validate_prompt_arguments!(prompt_name, prompt, arguments)
|
|
210
|
-
|
|
211
|
-
# Call the registered handler after arguments were validated
|
|
212
|
-
result_data = prompt.handler.call(arguments)
|
|
213
|
-
|
|
214
|
-
validate_prompt_response!(prompt_name, result_data, server)
|
|
215
|
-
|
|
216
|
-
# Set result in context
|
|
217
|
-
context.result = result_data
|
|
218
|
-
|
|
219
|
-
# Execute after_prompt_get hooks
|
|
220
|
-
context = server.middleware_manager.execute_hooks(:after_prompt_get, context)
|
|
221
|
-
|
|
222
|
-
context.result
|
|
200
|
+
execute_prompt_request(context, prompt_name, session, server)
|
|
223
201
|
rescue StandardError => e
|
|
224
202
|
# Set error in context and execute error hooks
|
|
225
203
|
context.error = e
|
|
@@ -405,13 +383,7 @@ module VectorMCP
|
|
|
405
383
|
# @param session [VectorMCP::Session] The current session
|
|
406
384
|
# @return [VectorMCP::RequestContext] Request context for security middleware
|
|
407
385
|
def self.extract_auth_credentials(session)
|
|
408
|
-
|
|
409
|
-
unless session.respond_to?(:request_context) && session.request_context
|
|
410
|
-
raise VectorMCP::InternalError,
|
|
411
|
-
"Session missing request_context - transport layer integration error. Session ID: #{session.id}"
|
|
412
|
-
end
|
|
413
|
-
|
|
414
|
-
session.request_context
|
|
386
|
+
VectorMCP::Security::RequestAuthenticator.request_context_for(session)
|
|
415
387
|
end
|
|
416
388
|
private_class_method :extract_auth_credentials
|
|
417
389
|
|
|
@@ -452,23 +424,12 @@ module VectorMCP
|
|
|
452
424
|
|
|
453
425
|
# Authenticate the current request and return the caller's identity.
|
|
454
426
|
def self.authenticate_request!(session, server, operation_type:, operation_name:)
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
session_context = server.security_middleware.authenticate_request(context.params)
|
|
463
|
-
session.security_context = session_context
|
|
464
|
-
|
|
465
|
-
raise VectorMCP::UnauthorizedError, "Authentication required" if server.auth_manager.required? && !session_context.authenticated?
|
|
466
|
-
|
|
467
|
-
context.result = session_context
|
|
468
|
-
server.middleware_manager.execute_hooks(:after_auth, context)
|
|
469
|
-
session_context
|
|
470
|
-
rescue StandardError => e
|
|
471
|
-
handle_auth_error(e, context, server)
|
|
427
|
+
VectorMCP::Security::RequestAuthenticator.authenticate!(
|
|
428
|
+
session,
|
|
429
|
+
server,
|
|
430
|
+
operation_type: operation_type,
|
|
431
|
+
operation_name: operation_name
|
|
432
|
+
)
|
|
472
433
|
end
|
|
473
434
|
|
|
474
435
|
# Execute tool handler with proper arity handling
|
|
@@ -525,6 +486,35 @@ module VectorMCP
|
|
|
525
486
|
raise VectorMCP::ForbiddenError, "Access denied"
|
|
526
487
|
end
|
|
527
488
|
|
|
489
|
+
# Filter catalog entries through their registered authorization policy.
|
|
490
|
+
# Listing intentionally omits denied items instead of revealing their existence.
|
|
491
|
+
def self.filter_authorized_items(items, session_context, server)
|
|
492
|
+
items.select do |item|
|
|
493
|
+
server.security_middleware.authorize_action(session_context, :list, item)
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# Authenticate, authorize, validate, and execute a prompt request.
|
|
498
|
+
def self.execute_prompt_request(context, prompt_name, session, server)
|
|
499
|
+
session_context = authenticate_request!(session, server, operation_type: :prompt_get, operation_name: prompt_name)
|
|
500
|
+
|
|
501
|
+
context = server.middleware_manager.execute_hooks(:before_prompt_get, context)
|
|
502
|
+
return handle_middleware_error(context) if context.error?
|
|
503
|
+
|
|
504
|
+
context_params = context.params
|
|
505
|
+
prompt_name = context_params["name"] || prompt_name
|
|
506
|
+
prompt = fetch_prompt(prompt_name, server)
|
|
507
|
+
authorize_action!(session_context, :get, prompt, server)
|
|
508
|
+
|
|
509
|
+
arguments = context_params["arguments"] || {}
|
|
510
|
+
validate_prompt_arguments!(prompt_name, prompt, arguments)
|
|
511
|
+
result_data = prompt.handler.call(arguments)
|
|
512
|
+
validate_prompt_response!(prompt_name, result_data, server)
|
|
513
|
+
|
|
514
|
+
context.result = result_data
|
|
515
|
+
server.middleware_manager.execute_hooks(:after_prompt_get, context).result
|
|
516
|
+
end
|
|
517
|
+
|
|
528
518
|
# Execute resource handler with proper arity handling.
|
|
529
519
|
# The second argument is the per-request invocation, which also exposes
|
|
530
520
|
# the SessionContext auth API for handlers written against the legacy
|
|
@@ -555,32 +545,11 @@ module VectorMCP
|
|
|
555
545
|
context.result
|
|
556
546
|
end
|
|
557
547
|
|
|
558
|
-
# Create middleware context for authentication hooks.
|
|
559
|
-
def self.create_auth_context(operation_type, operation_name, request, session, server)
|
|
560
|
-
VectorMCP::Middleware::Context.new(
|
|
561
|
-
operation_type: operation_type,
|
|
562
|
-
operation_name: operation_name,
|
|
563
|
-
params: request,
|
|
564
|
-
session: session,
|
|
565
|
-
server: server,
|
|
566
|
-
metadata: { start_time: Time.now }
|
|
567
|
-
)
|
|
568
|
-
end
|
|
569
|
-
|
|
570
|
-
# Run auth error hooks and re-raise the original error.
|
|
571
|
-
def self.handle_auth_error(error, context, server)
|
|
572
|
-
return raise error unless context
|
|
573
|
-
|
|
574
|
-
context.error = error
|
|
575
|
-
server.middleware_manager.execute_hooks(:on_auth_error, context)
|
|
576
|
-
raise error
|
|
577
|
-
end
|
|
578
|
-
|
|
579
548
|
private_class_method :handle_middleware_error, :create_tool_context, :find_tool!, :authenticate_request!,
|
|
580
549
|
:execute_tool_handler, :build_tool_result, :handle_tool_error, :create_resource_context,
|
|
581
|
-
:find_resource!, :authorize_action!, :
|
|
582
|
-
:
|
|
583
|
-
:
|
|
550
|
+
:find_resource!, :authorize_action!, :filter_authorized_items, :execute_prompt_request,
|
|
551
|
+
:execute_resource_handler,
|
|
552
|
+
:process_resource_content, :handle_resource_error
|
|
584
553
|
end
|
|
585
554
|
end
|
|
586
555
|
end
|
|
@@ -29,7 +29,7 @@ module VectorMCP
|
|
|
29
29
|
attr_reader :request_context
|
|
30
30
|
|
|
31
31
|
# @return [VectorMCP::Security::SessionContext] the security context resolved for this request
|
|
32
|
-
|
|
32
|
+
attr_reader :security_context
|
|
33
33
|
|
|
34
34
|
def_delegators :@session,
|
|
35
35
|
:id, :server, :transport, :data,
|
|
@@ -53,6 +53,19 @@ module VectorMCP
|
|
|
53
53
|
@session = session
|
|
54
54
|
@request_context = request_context ? RequestContext.coerce(request_context) : session.request_context
|
|
55
55
|
@security_context = Security::SessionContext.anonymous
|
|
56
|
+
@authentication_resolved = false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Store the identity resolved for this invocation. Assigning a context marks
|
|
60
|
+
# authentication as complete even when the resolved identity is anonymous.
|
|
61
|
+
def security_context=(context)
|
|
62
|
+
@security_context = context
|
|
63
|
+
@authentication_resolved = true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Boolean] whether authentication has run for this invocation
|
|
67
|
+
def authentication_resolved?
|
|
68
|
+
@authentication_resolved
|
|
56
69
|
end
|
|
57
70
|
|
|
58
71
|
# Replace this request's context (e.g. from middleware).
|
|
@@ -90,9 +103,8 @@ module VectorMCP
|
|
|
90
103
|
@request_context.param(name)
|
|
91
104
|
end
|
|
92
105
|
|
|
93
|
-
# Invocations compare by their underlying session
|
|
94
|
-
#
|
|
95
|
-
# view of one session as the same subscriber.
|
|
106
|
+
# Invocations compare by their underlying session so request-scoped views
|
|
107
|
+
# retain the identity semantics of that shared session.
|
|
96
108
|
def ==(other)
|
|
97
109
|
other_session = other.is_a?(Invocation) ? other.session : other
|
|
98
110
|
@session == other_session
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
require_relative "../request_context"
|
|
6
|
+
|
|
7
|
+
module VectorMCP
|
|
8
|
+
module Security
|
|
9
|
+
# In-process limiter for repeated authentication failures.
|
|
10
|
+
#
|
|
11
|
+
# Attempts are tracked independently by transport-provided remote address
|
|
12
|
+
# and by a SHA-256 fingerprint of the presented credential. Raw credentials
|
|
13
|
+
# are never retained. Storage is bounded to avoid turning the limiter into a
|
|
14
|
+
# memory-exhaustion primitive.
|
|
15
|
+
class AuthenticationRateLimiter
|
|
16
|
+
DEFAULT_MAX_ATTEMPTS = 10
|
|
17
|
+
DEFAULT_WINDOW_SECONDS = 60
|
|
18
|
+
DEFAULT_MAX_ENTRIES = 10_000
|
|
19
|
+
|
|
20
|
+
CREDENTIAL_HEADERS = %w[Authorization X-API-Key X-JWT-Token].freeze
|
|
21
|
+
CREDENTIAL_PARAMS = %w[api_key apikey jwt_token token].freeze
|
|
22
|
+
|
|
23
|
+
attr_reader :max_attempts, :window_seconds, :max_entries
|
|
24
|
+
|
|
25
|
+
# @param max_attempts [Integer] failures allowed per identifier and window
|
|
26
|
+
# @param window_seconds [Numeric] rolling failure window in seconds
|
|
27
|
+
# @param max_entries [Integer] maximum number of tracked identifiers
|
|
28
|
+
# @param clock [#call, nil] monotonic time source (primarily for testing)
|
|
29
|
+
def initialize(max_attempts: DEFAULT_MAX_ATTEMPTS,
|
|
30
|
+
window_seconds: DEFAULT_WINDOW_SECONDS,
|
|
31
|
+
max_entries: DEFAULT_MAX_ENTRIES,
|
|
32
|
+
clock: nil)
|
|
33
|
+
validate_positive_integer!(:max_attempts, max_attempts)
|
|
34
|
+
validate_positive_number!(:window_seconds, window_seconds)
|
|
35
|
+
validate_positive_integer!(:max_entries, max_entries)
|
|
36
|
+
|
|
37
|
+
@max_attempts = max_attempts
|
|
38
|
+
@window_seconds = window_seconds
|
|
39
|
+
@max_entries = max_entries
|
|
40
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
41
|
+
@attempts = {}
|
|
42
|
+
@mutex = Mutex.new
|
|
43
|
+
@logger = VectorMCP.logger_for("security.auth_rate_limit")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Atomically reserve an attempt, raising when any request identifier is blocked.
|
|
47
|
+
# @param request [VectorMCP::RequestContext, Hash] incoming request context
|
|
48
|
+
# @return [void]
|
|
49
|
+
# @raise [VectorMCP::RateLimitExceededError]
|
|
50
|
+
def check!(request)
|
|
51
|
+
context = VectorMCP::RequestContext.coerce(request)
|
|
52
|
+
current_time = now
|
|
53
|
+
identifiers = request_identifiers(context)
|
|
54
|
+
retry_after = @mutex.synchronize do
|
|
55
|
+
blocked_for = identifiers.filter_map do |identifier|
|
|
56
|
+
timestamps = active_timestamps(identifier, current_time)
|
|
57
|
+
next unless timestamps.length >= @max_attempts
|
|
58
|
+
|
|
59
|
+
timestamps.first + @window_seconds - current_time
|
|
60
|
+
end.max
|
|
61
|
+
unless blocked_for
|
|
62
|
+
identifiers.each do |identifier|
|
|
63
|
+
timestamps = active_timestamps(identifier, current_time)
|
|
64
|
+
timestamps << current_time
|
|
65
|
+
store(identifier, timestamps)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
blocked_for
|
|
69
|
+
end
|
|
70
|
+
return unless retry_after
|
|
71
|
+
|
|
72
|
+
seconds = [retry_after.ceil, 1].max
|
|
73
|
+
log_rate_limit(context, seconds)
|
|
74
|
+
raise VectorMCP::RateLimitExceededError.new(retry_after: seconds)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Record an unsuccessful authentication result for the reserved attempt.
|
|
78
|
+
# @param request [VectorMCP::RequestContext, Hash] incoming request context
|
|
79
|
+
# @return [void]
|
|
80
|
+
def record_failure(request)
|
|
81
|
+
context = VectorMCP::RequestContext.coerce(request)
|
|
82
|
+
current_time = now
|
|
83
|
+
threshold_reached = @mutex.synchronize do
|
|
84
|
+
request_identifiers(context).filter_map do |identifier|
|
|
85
|
+
timestamps = active_timestamps(identifier, current_time)
|
|
86
|
+
identifier_scope(identifier) if timestamps.length == @max_attempts
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
log_failure_threshold(context, threshold_reached) unless threshold_reached.empty?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Clear failure history after successful authentication.
|
|
94
|
+
# @param request [VectorMCP::RequestContext, Hash] incoming request context
|
|
95
|
+
# @return [void]
|
|
96
|
+
def record_success(request)
|
|
97
|
+
identifiers = request_identifiers(VectorMCP::RequestContext.coerce(request))
|
|
98
|
+
@mutex.synchronize { identifiers.each { |identifier| @attempts.delete(identifier) } }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @return [Hash] non-sensitive limiter status
|
|
102
|
+
def status
|
|
103
|
+
tracked_entries = @mutex.synchronize { @attempts.length }
|
|
104
|
+
{
|
|
105
|
+
enabled: true,
|
|
106
|
+
max_attempts: @max_attempts,
|
|
107
|
+
window_seconds: @window_seconds,
|
|
108
|
+
max_entries: @max_entries,
|
|
109
|
+
tracked_entries: tracked_entries
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def now
|
|
116
|
+
@clock.call
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def request_identifiers(context)
|
|
120
|
+
identifiers = []
|
|
121
|
+
remote_addr = context.metadata("remote_addr").to_s.strip
|
|
122
|
+
identifiers << "ip:#{remote_addr}" unless remote_addr.empty?
|
|
123
|
+
|
|
124
|
+
credential = extract_credential(context)
|
|
125
|
+
identifiers << "credential:#{Digest::SHA256.hexdigest(credential)}" if credential
|
|
126
|
+
|
|
127
|
+
identifiers.empty? ? ["source:unknown"] : identifiers
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def extract_credential(context)
|
|
131
|
+
credential_values = context.headers.filter_map do |name, item|
|
|
132
|
+
item if CREDENTIAL_HEADERS.any? { |credential_header| credential_header.casecmp?(name) }
|
|
133
|
+
end
|
|
134
|
+
value = credential_values.find { |item| !item.strip.empty? }
|
|
135
|
+
value ||= CREDENTIAL_PARAMS.filter_map { |name| context.param(name) }.find { |item| !item.strip.empty? }
|
|
136
|
+
value&.strip
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def active_timestamps(identifier, current_time)
|
|
140
|
+
cutoff = current_time - @window_seconds
|
|
141
|
+
(@attempts[identifier] || []).drop_while { |timestamp| timestamp <= cutoff }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def store(identifier, timestamps)
|
|
145
|
+
@attempts.delete(identifier)
|
|
146
|
+
@attempts.shift while @attempts.length >= @max_entries
|
|
147
|
+
@attempts[identifier] = timestamps.last(@max_attempts)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def identifier_scope(identifier)
|
|
151
|
+
identifier.split(":", 2).first
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def log_failure_threshold(context, scopes)
|
|
155
|
+
@logger.security(
|
|
156
|
+
"Repeated authentication failures reached configured limit",
|
|
157
|
+
event: "authentication_failure_limit_reached",
|
|
158
|
+
remote_addr: context.metadata("remote_addr"),
|
|
159
|
+
scopes: scopes.uniq.join(","),
|
|
160
|
+
max_attempts: @max_attempts,
|
|
161
|
+
window_seconds: @window_seconds
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def log_rate_limit(context, retry_after)
|
|
166
|
+
@logger.security(
|
|
167
|
+
"Authentication attempt rate limited",
|
|
168
|
+
event: "authentication_rate_limited",
|
|
169
|
+
remote_addr: context.metadata("remote_addr"),
|
|
170
|
+
retry_after: retry_after
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def validate_positive_integer!(name, value)
|
|
175
|
+
return if value.is_a?(Integer) && value.positive?
|
|
176
|
+
|
|
177
|
+
raise ArgumentError, "#{name} must be a positive Integer"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def validate_positive_number!(name, value)
|
|
181
|
+
return if value.is_a?(Numeric) && value.positive?
|
|
182
|
+
|
|
183
|
+
raise ArgumentError, "#{name} must be a positive number"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -6,6 +6,7 @@ module VectorMCP
|
|
|
6
6
|
# Integrates with transport layers to provide security controls
|
|
7
7
|
class Middleware
|
|
8
8
|
attr_reader :auth_manager, :authorization
|
|
9
|
+
attr_accessor :authentication_rate_limiter
|
|
9
10
|
|
|
10
11
|
# Initialize middleware with auth components
|
|
11
12
|
# @param auth_manager [AuthManager] the authentication manager
|
|
@@ -20,8 +21,13 @@ module VectorMCP
|
|
|
20
21
|
# @param strategy [Symbol] optional authentication strategy override
|
|
21
22
|
# @return [SessionContext] the session context for the request
|
|
22
23
|
def authenticate_request(request, strategy: nil)
|
|
24
|
+
request_context = rate_limit_request_context(request)
|
|
25
|
+
@authentication_rate_limiter&.check!(request_context) if request_context
|
|
26
|
+
|
|
23
27
|
auth_result = @auth_manager.authenticate(request, strategy: strategy)
|
|
24
|
-
SessionContext.from_auth_result(auth_result)
|
|
28
|
+
session_context = SessionContext.from_auth_result(auth_result)
|
|
29
|
+
update_rate_limit(request_context, session_context)
|
|
30
|
+
session_context
|
|
25
31
|
end
|
|
26
32
|
|
|
27
33
|
# Check if a session is authorized for an action on a resource
|
|
@@ -86,7 +92,8 @@ module VectorMCP
|
|
|
86
92
|
authentication: {
|
|
87
93
|
enabled: @auth_manager.required?,
|
|
88
94
|
strategies: @auth_manager.available_strategies,
|
|
89
|
-
default_strategy: @auth_manager.default_strategy
|
|
95
|
+
default_strategy: @auth_manager.default_strategy,
|
|
96
|
+
rate_limit: @authentication_rate_limiter&.status || { enabled: false }
|
|
90
97
|
},
|
|
91
98
|
authorization: {
|
|
92
99
|
enabled: @authorization.required?,
|
|
@@ -94,6 +101,26 @@ module VectorMCP
|
|
|
94
101
|
}
|
|
95
102
|
}
|
|
96
103
|
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def rate_limit_request_context(request)
|
|
108
|
+
return unless @authentication_rate_limiter
|
|
109
|
+
|
|
110
|
+
VectorMCP::RequestContext.coerce(request)
|
|
111
|
+
rescue ArgumentError
|
|
112
|
+
VectorMCP::RequestContext.minimal("unknown")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def update_rate_limit(request, session_context)
|
|
116
|
+
return unless @authentication_rate_limiter
|
|
117
|
+
|
|
118
|
+
if session_context.authenticated?
|
|
119
|
+
@authentication_rate_limiter.record_success(request)
|
|
120
|
+
else
|
|
121
|
+
@authentication_rate_limiter.record_failure(request)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
97
124
|
end
|
|
98
125
|
end
|
|
99
126
|
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../middleware"
|
|
4
|
+
require_relative "../request_context"
|
|
5
|
+
|
|
6
|
+
module VectorMCP
|
|
7
|
+
module Security
|
|
8
|
+
# Authenticates one request-scoped invocation and runs the auth middleware hooks.
|
|
9
|
+
# The resolved identity is cached only on Invocation, never on the shared Session.
|
|
10
|
+
class RequestAuthenticator
|
|
11
|
+
OPERATION_TYPES = {
|
|
12
|
+
"tools/list" => :tool_list,
|
|
13
|
+
"tools/call" => :tool_call,
|
|
14
|
+
"resources/list" => :resource_list,
|
|
15
|
+
"resources/read" => :resource_read,
|
|
16
|
+
"prompts/list" => :prompt_list,
|
|
17
|
+
"prompts/get" => :prompt_get,
|
|
18
|
+
"roots/list" => :root_list
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
OPERATION_NAME_PARAMS = {
|
|
22
|
+
"tools/call" => "name",
|
|
23
|
+
"resources/read" => "uri",
|
|
24
|
+
"prompts/get" => "name"
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# Authenticate a protocol message using operation metadata derived from its method.
|
|
29
|
+
def authenticate_message!(method, params, session, server)
|
|
30
|
+
operation_type = OPERATION_TYPES.fetch(method, :request)
|
|
31
|
+
name_param = OPERATION_NAME_PARAMS[method]
|
|
32
|
+
operation_name = name_param ? params[name_param] || params[name_param.to_sym] : method
|
|
33
|
+
|
|
34
|
+
authenticate!(session, server, operation_type: operation_type, operation_name: operation_name)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Authenticate the current request and return its resolved identity.
|
|
38
|
+
def authenticate!(session, server, operation_type:, operation_name:)
|
|
39
|
+
return session.security_context if authentication_resolved?(session)
|
|
40
|
+
|
|
41
|
+
context = nil
|
|
42
|
+
request = request_context_for(session)
|
|
43
|
+
context = auth_context(operation_type, operation_name, request.to_h, session, server)
|
|
44
|
+
context = server.middleware_manager.execute_hooks(:before_auth, context)
|
|
45
|
+
raise context.error if context.error?
|
|
46
|
+
|
|
47
|
+
session_context = server.security_middleware.authenticate_request(context.params)
|
|
48
|
+
session.security_context = session_context
|
|
49
|
+
|
|
50
|
+
raise VectorMCP::UnauthorizedError, "Authentication required" if server.auth_manager.required? && !session_context.authenticated?
|
|
51
|
+
|
|
52
|
+
context.result = session_context
|
|
53
|
+
server.middleware_manager.execute_hooks(:after_auth, context)
|
|
54
|
+
session_context
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
handle_auth_error(e, context, server)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Extract the immutable request context carried by the invocation/session.
|
|
60
|
+
def request_context_for(session)
|
|
61
|
+
unless session.respond_to?(:request_context) && session.request_context
|
|
62
|
+
raise VectorMCP::InternalError,
|
|
63
|
+
"Session missing request_context - transport layer integration error. Session ID: #{session.id}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
session.request_context
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def authentication_resolved?(session)
|
|
72
|
+
session.respond_to?(:authentication_resolved?) && session.authentication_resolved?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def auth_context(operation_type, operation_name, request, session, server)
|
|
76
|
+
VectorMCP::Middleware::Context.new(
|
|
77
|
+
operation_type: operation_type,
|
|
78
|
+
operation_name: operation_name,
|
|
79
|
+
params: request,
|
|
80
|
+
session: session,
|
|
81
|
+
server: server,
|
|
82
|
+
metadata: { start_time: Time.now }
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def handle_auth_error(error, context, server)
|
|
87
|
+
if context
|
|
88
|
+
context.error = error
|
|
89
|
+
server.middleware_manager.execute_hooks(:on_auth_error, context)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
raise error
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -54,13 +54,6 @@ module VectorMCP
|
|
|
54
54
|
send_list_changed_notification("roots") if @roots_list_changed
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
-
# Registers a session as a subscriber to prompt list changes.
|
|
58
|
-
# @api private
|
|
59
|
-
def subscribe_prompts(session)
|
|
60
|
-
@prompt_subscribers << session unless @prompt_subscribers.include?(session)
|
|
61
|
-
# Session subscribed to prompt list changes
|
|
62
|
-
end
|
|
63
|
-
|
|
64
57
|
private
|
|
65
58
|
|
|
66
59
|
# Sends a `notifications/<kind>/list_changed` notification to the transport.
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../security/request_authenticator"
|
|
4
|
+
|
|
3
5
|
module VectorMCP
|
|
4
6
|
class Server
|
|
5
7
|
# Handles message processing and request/notification dispatching
|
|
6
8
|
module MessageHandling
|
|
9
|
+
PUBLIC_REQUEST_METHODS = %w[initialize ping].freeze
|
|
10
|
+
PUBLIC_NOTIFICATION_METHODS = %w[initialized].freeze
|
|
11
|
+
|
|
7
12
|
# --- Message Handling Logic (primarily called by transports) ---
|
|
8
13
|
|
|
9
14
|
# Handles an incoming JSON-RPC message (request or notification).
|
|
@@ -12,12 +17,11 @@ module VectorMCP
|
|
|
12
17
|
# @param message [Hash] The parsed JSON-RPC message object.
|
|
13
18
|
# @param session [VectorMCP::Session, VectorMCP::Invocation] The session (or per-request
|
|
14
19
|
# invocation view of it) this message belongs to.
|
|
15
|
-
# @param session_id [String, nil] Deprecated: defaults to session.id; used only for log prefixes.
|
|
16
20
|
# @return [Object, nil] For requests, returns the result data to be sent in the JSON-RPC response.
|
|
17
21
|
# For notifications, returns `nil`.
|
|
18
22
|
# @raise [VectorMCP::ProtocolError] if the message is invalid or an error occurs during handling.
|
|
19
|
-
def handle_message(message, session
|
|
20
|
-
session_id
|
|
23
|
+
def handle_message(message, session)
|
|
24
|
+
session_id = session.id
|
|
21
25
|
id = message["id"]
|
|
22
26
|
method = message["method"]
|
|
23
27
|
params = message["params"] || {}
|
|
@@ -77,11 +81,7 @@ module VectorMCP
|
|
|
77
81
|
# @api private
|
|
78
82
|
def handle_request(id, method, params, session)
|
|
79
83
|
validate_session_initialization(id, method, params, session)
|
|
80
|
-
|
|
81
|
-
handler = @request_handlers[method]
|
|
82
|
-
raise VectorMCP::MethodNotFoundError.new(method, request_id: id) unless handler
|
|
83
|
-
|
|
84
|
-
execute_request_handler(id, method, params, session, handler)
|
|
84
|
+
execute_request_handler(id, method, params, session)
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
# Validates that the session is properly initialized for the given request.
|
|
@@ -98,8 +98,14 @@ module VectorMCP
|
|
|
98
98
|
|
|
99
99
|
# Executes the request handler with proper error handling and tracking.
|
|
100
100
|
# @api private
|
|
101
|
-
def execute_request_handler(id, method, params, session
|
|
101
|
+
def execute_request_handler(id, method, params, session)
|
|
102
102
|
@in_flight_requests[id] = { method: method, params: params, session: session, start_time: Time.now }
|
|
103
|
+
|
|
104
|
+
authenticate_message!(method, params, session) unless PUBLIC_REQUEST_METHODS.include?(method)
|
|
105
|
+
|
|
106
|
+
handler = @request_handlers[method]
|
|
107
|
+
raise VectorMCP::MethodNotFoundError.new(method, request_id: id) unless handler
|
|
108
|
+
|
|
103
109
|
result = handler.call(params, session, self)
|
|
104
110
|
result
|
|
105
111
|
rescue VectorMCP::ProtocolError => e
|
|
@@ -134,6 +140,7 @@ module VectorMCP
|
|
|
134
140
|
handler = @notification_handlers[method]
|
|
135
141
|
if handler
|
|
136
142
|
begin
|
|
143
|
+
authenticate_message!(method, params, session) unless PUBLIC_NOTIFICATION_METHODS.include?(method)
|
|
137
144
|
handler.call(params, session, self)
|
|
138
145
|
rescue StandardError => e
|
|
139
146
|
logger.error("Error executing notification handler '#{method}': #{e.message}\nBacktrace (top 5):\n #{e.backtrace.first(5).join("\n ")}")
|
|
@@ -156,7 +163,6 @@ module VectorMCP
|
|
|
156
163
|
on_request("resources/read", &Handlers::Core.method(:read_resource))
|
|
157
164
|
on_request("prompts/list", &Handlers::Core.method(:list_prompts))
|
|
158
165
|
on_request("prompts/get", &Handlers::Core.method(:get_prompt))
|
|
159
|
-
on_request("prompts/subscribe", &Handlers::Core.method(:subscribe_prompts))
|
|
160
166
|
on_request("roots/list", &Handlers::Core.method(:list_roots))
|
|
161
167
|
|
|
162
168
|
# Core Notifications
|
|
@@ -174,6 +180,12 @@ module VectorMCP
|
|
|
174
180
|
session.public_send(method_name, params)
|
|
175
181
|
end
|
|
176
182
|
end
|
|
183
|
+
|
|
184
|
+
def authenticate_message!(method, params, session)
|
|
185
|
+
return unless auth_manager.required?
|
|
186
|
+
|
|
187
|
+
VectorMCP::Security::RequestAuthenticator.authenticate_message!(method, params, session, self)
|
|
188
|
+
end
|
|
177
189
|
end
|
|
178
190
|
end
|
|
179
191
|
end
|
data/lib/vector_mcp/server.rb
CHANGED
|
@@ -11,6 +11,7 @@ require_relative "server/registry"
|
|
|
11
11
|
require_relative "server/capabilities"
|
|
12
12
|
require_relative "server/message_handling"
|
|
13
13
|
require_relative "security/auth_manager"
|
|
14
|
+
require_relative "security/authentication_rate_limiter"
|
|
14
15
|
require_relative "security/authorization"
|
|
15
16
|
require_relative "security/middleware"
|
|
16
17
|
require_relative "security/session_context"
|
|
@@ -72,7 +73,8 @@ module VectorMCP
|
|
|
72
73
|
SUPPORTED_PROTOCOL_VERSIONS = %w[2025-11-25 2025-03-26 2024-11-05].freeze
|
|
73
74
|
|
|
74
75
|
attr_reader :logger, :name, :version, :protocol_version, :tools, :resources, :prompts, :roots, :in_flight_requests,
|
|
75
|
-
:auth_manager, :authorization, :security_middleware, :middleware_manager, :oauth_resource_metadata_url
|
|
76
|
+
:auth_manager, :authorization, :security_middleware, :middleware_manager, :oauth_resource_metadata_url,
|
|
77
|
+
:authentication_rate_limiter
|
|
76
78
|
attr_accessor :transport
|
|
77
79
|
|
|
78
80
|
# Initializes a new VectorMCP server.
|
|
@@ -112,7 +114,6 @@ module VectorMCP
|
|
|
112
114
|
@notification_handlers = {}
|
|
113
115
|
@in_flight_requests = {}
|
|
114
116
|
@prompts_list_changed = false
|
|
115
|
-
@prompt_subscribers = []
|
|
116
117
|
@roots_list_changed = false
|
|
117
118
|
|
|
118
119
|
# Configure sampling capabilities
|
|
@@ -123,6 +124,7 @@ module VectorMCP
|
|
|
123
124
|
@authorization = Security::Authorization.new
|
|
124
125
|
@security_middleware = Security::Middleware.new(@auth_manager, @authorization)
|
|
125
126
|
@oauth_resource_metadata_url = nil
|
|
127
|
+
@authentication_rate_limiter = nil
|
|
126
128
|
|
|
127
129
|
# Initialize middleware manager
|
|
128
130
|
@middleware_manager = Middleware::Manager.new
|
|
@@ -195,10 +197,13 @@ module VectorMCP
|
|
|
195
197
|
# enabling MCP clients (e.g. Claude Desktop) to discover the authorization server and
|
|
196
198
|
# initiate an OAuth 2.1 flow. When omitted (default), auth failures continue to surface
|
|
197
199
|
# as JSON-RPC +-32401+ errors — existing behavior is preserved for non-OAuth deployments.
|
|
200
|
+
# @option options [Hash, Boolean] :rate_limit Optional authentication failure limiter.
|
|
201
|
+
# Pass +true+ for defaults or configure +:max_attempts+, +:window_seconds+, and +:max_entries+.
|
|
198
202
|
# @return [void]
|
|
199
203
|
def enable_authentication!(strategy: :api_key, **options, &block)
|
|
200
204
|
clear_auth_strategies unless @auth_manager.strategies.empty?
|
|
201
205
|
extract_oauth_metadata!(options)
|
|
206
|
+
configure_authentication_rate_limit!(options.delete(:rate_limit))
|
|
202
207
|
@auth_manager.enable!(default_strategy: strategy)
|
|
203
208
|
register_auth_strategy(strategy, options, block || options.delete(:handler))
|
|
204
209
|
@logger.info("Authentication enabled with strategy: #{strategy}")
|
|
@@ -209,6 +214,7 @@ module VectorMCP
|
|
|
209
214
|
def disable_authentication!
|
|
210
215
|
@auth_manager.disable!
|
|
211
216
|
@oauth_resource_metadata_url = nil
|
|
217
|
+
configure_authentication_rate_limit!(nil)
|
|
212
218
|
@logger.info("Authentication disabled")
|
|
213
219
|
end
|
|
214
220
|
|
|
@@ -318,6 +324,23 @@ module VectorMCP
|
|
|
318
324
|
warn_on_insecure_oauth_metadata_url(@oauth_resource_metadata_url)
|
|
319
325
|
end
|
|
320
326
|
|
|
327
|
+
# Configure the optional in-process authentication failure limiter.
|
|
328
|
+
# @param configuration [Hash, true, false, nil] limiter configuration
|
|
329
|
+
# @return [void]
|
|
330
|
+
def configure_authentication_rate_limit!(configuration)
|
|
331
|
+
@authentication_rate_limiter = case configuration
|
|
332
|
+
when true
|
|
333
|
+
Security::AuthenticationRateLimiter.new
|
|
334
|
+
when Hash
|
|
335
|
+
Security::AuthenticationRateLimiter.new(**configuration)
|
|
336
|
+
when false, nil
|
|
337
|
+
nil
|
|
338
|
+
else
|
|
339
|
+
raise ArgumentError, "rate_limit must be true, false, nil, or a Hash"
|
|
340
|
+
end
|
|
341
|
+
@security_middleware.authentication_rate_limiter = @authentication_rate_limiter
|
|
342
|
+
end
|
|
343
|
+
|
|
321
344
|
# Register the appropriate auth strategy based on the strategy name
|
|
322
345
|
# @param strategy [Symbol] the strategy type
|
|
323
346
|
# @param options [Hash] strategy-specific options
|
data/lib/vector_mcp/session.rb
CHANGED
|
@@ -113,69 +113,11 @@ module VectorMCP
|
|
|
113
113
|
Time.now - @created_at
|
|
114
114
|
end
|
|
115
115
|
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
# @return [RequestContext] The newly set request context.
|
|
122
|
-
# @raise [ArgumentError] If the context is not a RequestContext or Hash.
|
|
123
|
-
def request_context=(context)
|
|
124
|
-
deprecated_request_api(:request_context=)
|
|
125
|
-
@request_context = case context
|
|
126
|
-
when RequestContext
|
|
127
|
-
context
|
|
128
|
-
when Hash
|
|
129
|
-
RequestContext.new(**context)
|
|
130
|
-
else
|
|
131
|
-
raise ArgumentError, "Request context must be a RequestContext or Hash, got #{context.class}"
|
|
132
|
-
end
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
# Updates the request context with new data.
|
|
136
|
-
#
|
|
137
|
-
# @deprecated Will be removed in 0.7. Use the per-request invocation's
|
|
138
|
-
# {VectorMCP::Invocation#update_request_context} instead.
|
|
139
|
-
# @param attributes [Hash] The attributes to merge into the request context.
|
|
140
|
-
# @return [RequestContext] The updated request context.
|
|
141
|
-
def update_request_context(**attributes)
|
|
142
|
-
deprecated_request_api(:update_request_context)
|
|
143
|
-
@request_context = @request_context.merge(attributes)
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
# @deprecated Will be removed in 0.7. Use the per-request invocation's
|
|
147
|
-
# {VectorMCP::Invocation#request_headers?} instead.
|
|
148
|
-
# @return [Boolean] True if the request context has headers.
|
|
149
|
-
def request_headers?
|
|
150
|
-
deprecated_request_api(:request_headers?)
|
|
151
|
-
@request_context.headers?
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
# @deprecated Will be removed in 0.7. Use the per-request invocation's
|
|
155
|
-
# {VectorMCP::Invocation#request_params?} instead.
|
|
156
|
-
# @return [Boolean] True if the request context has parameters.
|
|
157
|
-
def request_params?
|
|
158
|
-
deprecated_request_api(:request_params?)
|
|
159
|
-
@request_context.params?
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
# @deprecated Will be removed in 0.7. Use the per-request invocation's
|
|
163
|
-
# {VectorMCP::Invocation#request_header} instead.
|
|
164
|
-
# @param name [String] The header name.
|
|
165
|
-
# @return [String, nil] The header value or nil if not found.
|
|
166
|
-
def request_header(name)
|
|
167
|
-
deprecated_request_api(:request_header)
|
|
168
|
-
@request_context.header(name)
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
# @deprecated Will be removed in 0.7. Use the per-request invocation's
|
|
172
|
-
# {VectorMCP::Invocation#request_param} instead.
|
|
173
|
-
# @param name [String] The parameter name.
|
|
174
|
-
# @return [String, nil] The parameter value or nil if not found.
|
|
175
|
-
def request_param(name)
|
|
176
|
-
deprecated_request_api(:request_param)
|
|
177
|
-
@request_context.param(name)
|
|
178
|
-
end
|
|
116
|
+
# NOTE: The request-scoped convenience API that used to live here
|
|
117
|
+
# (request_context=, update_request_context, request_headers?,
|
|
118
|
+
# request_params?, request_header, request_param) was removed in 0.7.
|
|
119
|
+
# Request-scoped data travels on the per-request {VectorMCP::Invocation}
|
|
120
|
+
# passed to handlers; the session only keeps its creation-time context.
|
|
179
121
|
|
|
180
122
|
# Helper to check client capabilities later if needed
|
|
181
123
|
# def supports?(capability_key)
|
|
@@ -238,16 +180,6 @@ module VectorMCP
|
|
|
238
180
|
|
|
239
181
|
private
|
|
240
182
|
|
|
241
|
-
# Emit the standard deprecation warning for the request-scoped Session API.
|
|
242
|
-
# @api private
|
|
243
|
-
def deprecated_request_api(method_name)
|
|
244
|
-
VectorMCP.deprecation_warning(
|
|
245
|
-
"Session##{method_name} is deprecated and will be removed in 0.7; " \
|
|
246
|
-
"request-scoped data lives on the per-request invocation passed to handlers " \
|
|
247
|
-
"(e.g. invocation.request_header)"
|
|
248
|
-
)
|
|
249
|
-
end
|
|
250
|
-
|
|
251
183
|
# Validates that sampling can be performed on this session.
|
|
252
184
|
# @api private
|
|
253
185
|
# @raise [StandardError, InitializationError] if preconditions are not met.
|
|
@@ -223,7 +223,7 @@ module VectorMCP
|
|
|
223
223
|
loop do
|
|
224
224
|
sleep(30) # Send heartbeat every 30 seconds
|
|
225
225
|
|
|
226
|
-
connection = @active_connections[stream_id]
|
|
226
|
+
connection = @active_connections[stream_id]
|
|
227
227
|
break if connection.nil? || connection.closed?
|
|
228
228
|
|
|
229
229
|
# Check if connection has been alive too long
|
|
@@ -278,11 +278,9 @@ module VectorMCP
|
|
|
278
278
|
# @return [void]
|
|
279
279
|
def cleanup_connection(session, connection = nil)
|
|
280
280
|
connection ||= session.streaming_connection
|
|
281
|
-
connection ||= @active_connections[session.id]
|
|
282
281
|
return unless connection
|
|
283
282
|
|
|
284
283
|
@active_connections.delete(connection.stream_id)
|
|
285
|
-
@active_connections.delete(session.id) if @active_connections[session.id] == connection
|
|
286
284
|
|
|
287
285
|
connection.close
|
|
288
286
|
@transport.session_manager.remove_streaming_connection(session, connection)
|
|
@@ -324,16 +322,9 @@ module VectorMCP
|
|
|
324
322
|
end
|
|
325
323
|
|
|
326
324
|
def active_connections_for_session(session)
|
|
327
|
-
|
|
325
|
+
session.streaming_connections.values.select do |connection|
|
|
328
326
|
@active_connections[connection.stream_id] == connection && !connection.closed?
|
|
329
327
|
end
|
|
330
|
-
|
|
331
|
-
legacy_connection = @active_connections[session.id]
|
|
332
|
-
connections << legacy_connection if legacy_connection &&
|
|
333
|
-
!legacy_connection.closed? &&
|
|
334
|
-
!connections.include?(legacy_connection)
|
|
335
|
-
|
|
336
|
-
connections
|
|
337
328
|
end
|
|
338
329
|
|
|
339
330
|
def preferred_connection_for(session, connections)
|
|
@@ -41,9 +41,10 @@ module VectorMCP
|
|
|
41
41
|
# @attr_reader host [String] The hostname or IP address the server will bind to
|
|
42
42
|
# @attr_reader port [Integer] The port number the server will listen on
|
|
43
43
|
# @attr_reader path_prefix [String] The base URL path for MCP endpoints
|
|
44
|
+
# @attr_reader max_body_bytes [Integer] Maximum accepted POST body size
|
|
44
45
|
# rubocop:disable Metrics/ClassLength
|
|
45
46
|
class HttpStream
|
|
46
|
-
attr_reader :logger, :server, :host, :port, :path_prefix
|
|
47
|
+
attr_reader :logger, :server, :host, :port, :path_prefix, :max_body_bytes
|
|
47
48
|
|
|
48
49
|
# Default configuration values
|
|
49
50
|
DEFAULT_HOST = "localhost"
|
|
@@ -54,6 +55,8 @@ module VectorMCP
|
|
|
54
55
|
DEFAULT_REQUEST_TIMEOUT = 30 # Default timeout for server-initiated requests
|
|
55
56
|
DEFAULT_MIN_THREADS = 4
|
|
56
57
|
DEFAULT_MAX_THREADS = 32
|
|
58
|
+
# Accommodates the 10 MiB ImageUtil limit after base64 expansion and JSON framing.
|
|
59
|
+
DEFAULT_MAX_BODY_BYTES = 16 * 1024 * 1024
|
|
57
60
|
|
|
58
61
|
# Default allowed origins — restrict to localhost by default for security.
|
|
59
62
|
DEFAULT_ALLOWED_ORIGINS = %w[
|
|
@@ -76,6 +79,7 @@ module VectorMCP
|
|
|
76
79
|
# @option options [Integer] :event_retention (100) Number of events to retain for resumability
|
|
77
80
|
# @option options [Integer] :min_threads (4) Minimum Puma thread pool size
|
|
78
81
|
# @option options [Integer] :max_threads (32) Maximum Puma thread pool size
|
|
82
|
+
# @option options [Integer] :max_body_bytes (16777216) Maximum POST body size in bytes
|
|
79
83
|
# @option options [Array<String>] :allowed_origins Allowed origins for CORS validation.
|
|
80
84
|
# Defaults to localhost origins only. Pass ["*"] to allow all origins (NOT recommended for production).
|
|
81
85
|
def initialize(server, options = {})
|
|
@@ -268,7 +272,13 @@ module VectorMCP
|
|
|
268
272
|
#
|
|
269
273
|
# @return [void]
|
|
270
274
|
def start_puma_server
|
|
271
|
-
@puma_server = Puma::Server.new(
|
|
275
|
+
@puma_server = Puma::Server.new(
|
|
276
|
+
self,
|
|
277
|
+
nil,
|
|
278
|
+
min_threads: @min_threads,
|
|
279
|
+
max_threads: @max_threads,
|
|
280
|
+
http_content_length_limit: @max_body_bytes
|
|
281
|
+
)
|
|
272
282
|
@puma_server.add_tcp_listener(@host, @port)
|
|
273
283
|
|
|
274
284
|
@running = true
|
|
@@ -349,7 +359,7 @@ module VectorMCP
|
|
|
349
359
|
# Validates origin and dispatches to the appropriate handler by HTTP method.
|
|
350
360
|
def validate_and_dispatch(method, env)
|
|
351
361
|
return forbidden_response("Origin not allowed") unless valid_origin?(env)
|
|
352
|
-
return
|
|
362
|
+
return unauthorized_transport_response(env) if transport_gate_should_reject?(env, method)
|
|
353
363
|
|
|
354
364
|
case method
|
|
355
365
|
when "POST"
|
|
@@ -361,16 +371,20 @@ module VectorMCP
|
|
|
361
371
|
else
|
|
362
372
|
method_not_allowed_response(%w[POST GET DELETE])
|
|
363
373
|
end
|
|
374
|
+
rescue VectorMCP::RateLimitExceededError => e
|
|
375
|
+
rate_limit_response(e)
|
|
364
376
|
end
|
|
365
377
|
|
|
366
|
-
#
|
|
367
|
-
#
|
|
368
|
-
#
|
|
378
|
+
# OAuth resource-server mode authenticates every MCP transport request.
|
|
379
|
+
# Other authentication modes authenticate GET/DELETE here because those
|
|
380
|
+
# operations do not pass through the JSON-RPC message dispatcher.
|
|
369
381
|
#
|
|
370
382
|
# @param env [Hash] The Rack environment
|
|
383
|
+
# @param method [String] The HTTP method
|
|
371
384
|
# @return [Boolean]
|
|
372
|
-
def
|
|
373
|
-
return false unless
|
|
385
|
+
def transport_gate_should_reject?(env, method)
|
|
386
|
+
return false unless @server.auth_manager.required?
|
|
387
|
+
return false unless oauth_resource_server_enabled? || %w[GET DELETE].include?(method)
|
|
374
388
|
|
|
375
389
|
!authenticate_transport_request(env).authenticated?
|
|
376
390
|
end
|
|
@@ -395,6 +409,8 @@ module VectorMCP
|
|
|
395
409
|
def authenticate_transport_request(env)
|
|
396
410
|
request_context = VectorMCP::RequestContext.from_rack_env(env, "http_stream")
|
|
397
411
|
@server.security_middleware.authenticate_request(request_context)
|
|
412
|
+
rescue VectorMCP::RateLimitExceededError
|
|
413
|
+
raise
|
|
398
414
|
rescue StandardError => e
|
|
399
415
|
VectorMCP.logger_for("security").warn do
|
|
400
416
|
"OAuth transport auth strategy raised #{e.class}: #{e.message}"
|
|
@@ -427,6 +443,24 @@ module VectorMCP
|
|
|
427
443
|
[body]]
|
|
428
444
|
end
|
|
429
445
|
|
|
446
|
+
# Returns a plain HTTP authentication challenge for non-OAuth API-key/JWT
|
|
447
|
+
# deployments. OAuth mode uses its RFC 9728 discovery response instead.
|
|
448
|
+
def unauthorized_transport_response(env)
|
|
449
|
+
return unauthorized_oauth_response(env) if oauth_resource_server_enabled?
|
|
450
|
+
|
|
451
|
+
VectorMCP.logger_for("security").info do
|
|
452
|
+
"HTTP transport 401 issued for #{env["REQUEST_METHOD"]} #{env["PATH_INFO"]}"
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
body = {
|
|
456
|
+
jsonrpc: "2.0",
|
|
457
|
+
id: nil,
|
|
458
|
+
error: { code: -32_401, message: "Authentication required" }
|
|
459
|
+
}.to_json
|
|
460
|
+
|
|
461
|
+
[401, { "Content-Type" => "application/json" }, [body]]
|
|
462
|
+
end
|
|
463
|
+
|
|
430
464
|
# Handles POST requests (client-to-server JSON-RPC)
|
|
431
465
|
#
|
|
432
466
|
# @param env [Hash] The Rack environment
|
|
@@ -458,6 +492,8 @@ module VectorMCP
|
|
|
458
492
|
end
|
|
459
493
|
|
|
460
494
|
handle_single_request(parsed, session, env)
|
|
495
|
+
rescue VectorMCP::PayloadTooLargeError => e
|
|
496
|
+
payload_too_large_response(e.message)
|
|
461
497
|
rescue JSON::ParserError => e
|
|
462
498
|
json_error_response(nil, -32_700, "Parse error", { details: e.message })
|
|
463
499
|
end
|
|
@@ -573,9 +609,15 @@ module VectorMCP
|
|
|
573
609
|
# @param env [Hash] The Rack environment
|
|
574
610
|
# @return [String] The request body
|
|
575
611
|
def read_request_body(env)
|
|
612
|
+
content_length = env["CONTENT_LENGTH"].to_i
|
|
613
|
+
raise VectorMCP::PayloadTooLargeError, @max_body_bytes if content_length > @max_body_bytes
|
|
614
|
+
|
|
576
615
|
input = env["rack.input"]
|
|
577
616
|
input.rewind
|
|
578
|
-
input.read
|
|
617
|
+
body = input.read(@max_body_bytes + 1) || ""
|
|
618
|
+
raise VectorMCP::PayloadTooLargeError, @max_body_bytes if body.bytesize > @max_body_bytes
|
|
619
|
+
|
|
620
|
+
body
|
|
579
621
|
end
|
|
580
622
|
|
|
581
623
|
# Optimized JSON parsing with better error handling and performance
|
|
@@ -655,6 +697,8 @@ module VectorMCP
|
|
|
655
697
|
end
|
|
656
698
|
|
|
657
699
|
def build_protocol_error_response(env, error, session_id: nil)
|
|
700
|
+
return rate_limit_response(error) if error.is_a?(VectorMCP::RateLimitExceededError)
|
|
701
|
+
|
|
658
702
|
if client_accepts_sse?(env)
|
|
659
703
|
sse_error_response(error.request_id, error.code, error.message, error.details, session_id: session_id)
|
|
660
704
|
else
|
|
@@ -662,6 +706,18 @@ module VectorMCP
|
|
|
662
706
|
end
|
|
663
707
|
end
|
|
664
708
|
|
|
709
|
+
def rate_limit_response(error)
|
|
710
|
+
body = {
|
|
711
|
+
jsonrpc: "2.0",
|
|
712
|
+
id: error.request_id,
|
|
713
|
+
error: { code: error.code, message: error.message, data: error.details }
|
|
714
|
+
}.to_json
|
|
715
|
+
|
|
716
|
+
[429,
|
|
717
|
+
{ "Content-Type" => "application/json", "Retry-After" => error.retry_after.to_s },
|
|
718
|
+
[body]]
|
|
719
|
+
end
|
|
720
|
+
|
|
665
721
|
def client_accepts_sse?(env)
|
|
666
722
|
accept = env["HTTP_ACCEPT"] || ""
|
|
667
723
|
accept.include?("text/event-stream")
|
|
@@ -744,6 +800,10 @@ module VectorMCP
|
|
|
744
800
|
[406, { "Content-Type" => "text/plain" }, [message]]
|
|
745
801
|
end
|
|
746
802
|
|
|
803
|
+
def payload_too_large_response(message = "Payload Too Large")
|
|
804
|
+
[413, { "Content-Type" => "text/plain", "Connection" => "close" }, [message]]
|
|
805
|
+
end
|
|
806
|
+
|
|
747
807
|
# Validates the MCP-Protocol-Version header per spec.
|
|
748
808
|
# Returns nil if valid, or a 400 Rack response if unsupported.
|
|
749
809
|
def validate_protocol_version_header(env)
|
|
@@ -984,12 +1044,19 @@ module VectorMCP
|
|
|
984
1044
|
@event_retention = options[:event_retention] || DEFAULT_EVENT_RETENTION
|
|
985
1045
|
@min_threads = options[:min_threads] || DEFAULT_MIN_THREADS
|
|
986
1046
|
@max_threads = options[:max_threads] || DEFAULT_MAX_THREADS
|
|
1047
|
+
@max_body_bytes = validate_max_body_bytes(options.fetch(:max_body_bytes, DEFAULT_MAX_BODY_BYTES))
|
|
987
1048
|
@allowed_origins = options[:allowed_origins] || DEFAULT_ALLOWED_ORIGINS
|
|
988
1049
|
@mounted = options.fetch(:mounted, false)
|
|
989
1050
|
|
|
990
1051
|
warn_on_permissive_origins if @allowed_origins.include?("*")
|
|
991
1052
|
end
|
|
992
1053
|
|
|
1054
|
+
def validate_max_body_bytes(value)
|
|
1055
|
+
raise ArgumentError, "max_body_bytes must be a positive Integer" unless value.is_a?(Integer) && value.positive?
|
|
1056
|
+
|
|
1057
|
+
value
|
|
1058
|
+
end
|
|
1059
|
+
|
|
993
1060
|
# Logs a security warning when wildcard origin is configured.
|
|
994
1061
|
def warn_on_permissive_origins
|
|
995
1062
|
logger.warn do
|
data/lib/vector_mcp/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: vector_mcp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sergio Bayona
|
|
@@ -145,8 +145,10 @@ files:
|
|
|
145
145
|
- lib/vector_mcp/security.rb
|
|
146
146
|
- lib/vector_mcp/security/auth_manager.rb
|
|
147
147
|
- lib/vector_mcp/security/auth_result.rb
|
|
148
|
+
- lib/vector_mcp/security/authentication_rate_limiter.rb
|
|
148
149
|
- lib/vector_mcp/security/authorization.rb
|
|
149
150
|
- lib/vector_mcp/security/middleware.rb
|
|
151
|
+
- lib/vector_mcp/security/request_authenticator.rb
|
|
150
152
|
- lib/vector_mcp/security/session_context.rb
|
|
151
153
|
- lib/vector_mcp/security/strategies/api_key.rb
|
|
152
154
|
- lib/vector_mcp/security/strategies/custom.rb
|