vector_mcp 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 19883e6171b2fc442256036ec7841130c8ef557f7b5a58c354aa3880c6910907
4
- data.tar.gz: 4e5d96fe5172319af0af30f8118704c469ccb9bfc543dead9907b4c42a0b0373
3
+ metadata.gz: b00aa3c214f8fcdd9906a9c349b8b871c0f3ab8fb9463d198e007d28783fe43d
4
+ data.tar.gz: 6053bad04ea796b29312812a4ac6e1c0dcf1ba2a9bc292cfd902e5e3793d78cb
5
5
  SHA512:
6
- metadata.gz: 276a4391c46cce453ecda4cdb1582f19b0b06f762a737c30e2369bdc4a3e42f6a95f83eef46b62e9cded7e4be28095f780bf0bc8edcfe2f533fea449cabd9e6e
7
- data.tar.gz: d57ebbcbd8d8bc3cbbbf110aa3b669ef54ba41fbdcc848f3dd06b2f3bc1610b6be7552ff67d3dd6467498d6240b9e0b761096d0f138656e5a189d4fdd48fac5f
6
+ metadata.gz: b5eec0da560953e468c81b7af75dd5f3274dce64617e287f085a70327c83ff2085ed6a30012cf9fbd27638c1e415ce8b40e77fb6f61b20a779b5b9bd48f1c47b
7
+ data.tar.gz: ff5cd45eb8237ccddc953811fafa1912c0dbd4b0a0afd03bd373107614e1552a675e2e710d43aefd4f8dd693e46c3e89fd16d964917012e5a6379ff813ff2948
data/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
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
+
1
12
  ## [0.7.0] – 2026-08-15
2
13
 
3
14
  ### Removed (deprecated in 0.6.0)
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: ["your-secret-key"]
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
 
@@ -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 _session [VectorMCP::Session] The current session (ignored).
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, _session, server)
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: server.tools.values.map(&:as_mcp_definition)
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 _session [VectorMCP::Session] The current session (ignored).
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, _session, server)
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: server.resources.values.map(&:as_mcp_definition)
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 _session [VectorMCP::Session] The current session (ignored).
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, _session, server)
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: server.prompts.values.map(&:as_mcp_definition)
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 _session [VectorMCP::Session] The current session (ignored).
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, _session, server)
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: server.roots.values.map(&:as_mcp_definition)
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 _session [VectorMCP::Session] The current session (ignored).
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
- context_params = context.params
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
- # All sessions should have a request_context - this is enforced by Session initialization
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
- request = extract_auth_credentials(session)
456
- # before_auth middleware hooks see (and may modify) the request as a hash;
457
- # AuthManager coerces it back into a RequestContext at its boundary.
458
- context = create_auth_context(operation_type, operation_name, request.to_h, session, server)
459
- context = server.middleware_manager.execute_hooks(:before_auth, context)
460
- raise context.error if context.error?
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!, :execute_resource_handler,
582
- :process_resource_content, :handle_resource_error, :create_auth_context,
583
- :handle_auth_error
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
- attr_accessor :security_context
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, so collections that
94
- # deduplicate sessions (e.g. prompt subscribers) treat every request
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).
@@ -76,11 +81,7 @@ module VectorMCP
76
81
  # @api private
77
82
  def handle_request(id, method, params, session)
78
83
  validate_session_initialization(id, method, params, session)
79
-
80
- handler = @request_handlers[method]
81
- raise VectorMCP::MethodNotFoundError.new(method, request_id: id) unless handler
82
-
83
- execute_request_handler(id, method, params, session, handler)
84
+ execute_request_handler(id, method, params, session)
84
85
  end
85
86
 
86
87
  # Validates that the session is properly initialized for the given request.
@@ -97,8 +98,14 @@ module VectorMCP
97
98
 
98
99
  # Executes the request handler with proper error handling and tracking.
99
100
  # @api private
100
- def execute_request_handler(id, method, params, session, handler)
101
+ def execute_request_handler(id, method, params, session)
101
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
+
102
109
  result = handler.call(params, session, self)
103
110
  result
104
111
  rescue VectorMCP::ProtocolError => e
@@ -133,6 +140,7 @@ module VectorMCP
133
140
  handler = @notification_handlers[method]
134
141
  if handler
135
142
  begin
143
+ authenticate_message!(method, params, session) unless PUBLIC_NOTIFICATION_METHODS.include?(method)
136
144
  handler.call(params, session, self)
137
145
  rescue StandardError => e
138
146
  logger.error("Error executing notification handler '#{method}': #{e.message}\nBacktrace (top 5):\n #{e.backtrace.first(5).join("\n ")}")
@@ -155,7 +163,6 @@ module VectorMCP
155
163
  on_request("resources/read", &Handlers::Core.method(:read_resource))
156
164
  on_request("prompts/list", &Handlers::Core.method(:list_prompts))
157
165
  on_request("prompts/get", &Handlers::Core.method(:get_prompt))
158
- on_request("prompts/subscribe", &Handlers::Core.method(:subscribe_prompts))
159
166
  on_request("roots/list", &Handlers::Core.method(:list_roots))
160
167
 
161
168
  # Core Notifications
@@ -173,6 +180,12 @@ module VectorMCP
173
180
  session.public_send(method_name, params)
174
181
  end
175
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
176
189
  end
177
190
  end
178
191
  end
@@ -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
@@ -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(self, nil, min_threads: @min_threads, max_threads: @max_threads)
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 unauthorized_oauth_response(env) if oauth_gate_should_reject?(env)
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
- # True when OAuth 2.1 resource server mode is enabled and the incoming
367
- # request has not successfully authenticated. Opt-in: only activates when the
368
- # server was configured with a +resource_metadata_url+ via +enable_authentication!+.
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 oauth_gate_should_reject?(env)
373
- return false unless oauth_resource_server_enabled?
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module VectorMCP
4
4
  # The current version of the VectorMCP gem.
5
- VERSION = "0.7.0"
5
+ VERSION = "0.7.1"
6
6
  end
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.7.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