rails-ai-bridge 3.5.1 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -0
  3. data/README.md +1 -1
  4. data/SECURITY.md +1 -0
  5. data/UPGRADING.md +17 -0
  6. data/docs/mcp-security.md +46 -0
  7. data/docs/skill-registry-guide.md +37 -0
  8. data/lib/generators/rails_ai_bridge/install/install_generator.rb +10 -0
  9. data/lib/rails_ai_bridge/config/mcp.rb +33 -0
  10. data/lib/rails_ai_bridge/config/registry.rb +9 -0
  11. data/lib/rails_ai_bridge/configuration.rb +10 -1
  12. data/lib/rails_ai_bridge/http_transport_app.rb +57 -9
  13. data/lib/rails_ai_bridge/instrumentation.rb +47 -0
  14. data/lib/rails_ai_bridge/introspectors/auth_introspector.rb +16 -2
  15. data/lib/rails_ai_bridge/introspectors/config_introspector.rb +20 -0
  16. data/lib/rails_ai_bridge/introspectors/gem_registry.rb +1 -0
  17. data/lib/rails_ai_bridge/mcp/authenticator.rb +15 -1
  18. data/lib/rails_ai_bridge/mcp/cache_rate_limiter.rb +68 -0
  19. data/lib/rails_ai_bridge/path_resolver.rb +21 -0
  20. data/lib/rails_ai_bridge/registry/lockfile.rb +109 -0
  21. data/lib/rails_ai_bridge/registry/pack_resolver.rb +39 -2
  22. data/lib/rails_ai_bridge/registry/skill_source_resolver.rb +34 -0
  23. data/lib/rails_ai_bridge/registry.rb +18 -0
  24. data/lib/rails_ai_bridge/serializers/context_summary.rb +1 -1
  25. data/lib/rails_ai_bridge/server.rb +25 -2
  26. data/lib/rails_ai_bridge/tasks/rails_ai_bridge.rake +13 -0
  27. data/lib/rails_ai_bridge/tool_result_cache.rb +138 -0
  28. data/lib/rails_ai_bridge/tools/get_model_details.rb +23 -5
  29. data/lib/rails_ai_bridge/tools/get_routes.rb +20 -3
  30. data/lib/rails_ai_bridge/tools/get_schema.rb +2 -2
  31. data/lib/rails_ai_bridge/version.rb +1 -1
  32. data/rails-ai-bridge.gemspec +2 -2
  33. metadata +9 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 86847cb1d8613336bb1538fdde0c64461dcc69d2423b48939629695f40c99688
4
- data.tar.gz: 65d9c1a4f11b3edc80a077e66c41e339ca845e4d10993d63cddef79be7ef9c5d
3
+ metadata.gz: b375ce08baa45d08d3c20625d27280862175bb022677e1f5c5ae90d1eb7a1493
4
+ data.tar.gz: 7d8de9d9b1642b447c03cc3b36820d2008dee648a475ebffaac038a9f081a178
5
5
  SHA512:
6
- metadata.gz: c6ae4378aa2f3321b0753fb7e111a4d6777bd4de63e5ade3bcf38047affac48ce86630490dcacd33610021f299833bc5d7dd24f9dc238614f60786d8fac42ea4
7
- data.tar.gz: 22caf59cc05827c7a5824f17c2dd1a5715b8ba62b70930ef300835bdbdc96506bdae1f2ec8590391272576c097363b45debb605f195e0e7c7b43c227f8a75289
6
+ metadata.gz: 6ced1f26479b6614723aaba77812ec4efe9ff35c107a40e4e8865ec88553eace62aa5bd990371c0eb1b2ae4bfd13d7983c1d101bb3403ef7b8e8922022150106
7
+ data.tar.gz: 39b6117bb858aa1dca13e65d05244aeccece63fb6afde51ee5ff1c0d3986512aabd3bd74e0ae00ee5ad0bda3382025573caebe7aa273155ff5a3fd85db03e812
data/CHANGELOG.md CHANGED
@@ -5,6 +5,50 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.6.0]
9
+
10
+ ### Changed
11
+
12
+ - **`mcp` minimum version raised to 0.25** (#92) — the gemspec lower bound is now `>= 0.25` (was `>= 0.10`), matching the minimum version the codebase actually requires. The upper bound remains `< 1.0`.
13
+ - **`rubydex` constraint tightened to `~> 0.2.9`** (#92) — was `~> 0.2.4`. This is a **minor breaking change** for users pinned to rubydex 0.2.4–0.2.8; update your lockfile with `bundle update rubydex`.
14
+ - **`simplecov` bumped to 1.0** (#92) — development dependency only; does not affect gem consumers. The test suite filter was migrated from `add_filter '/spec/'` to `skip 'spec'` per the simplecov 1.0 migration guide (`SourceFile#project_filename` no longer includes a leading separator).
15
+
16
+ ### Fixed
17
+
18
+ - **`Style/ArrayIntersect` lint offense** (#91/#92) — pre-existing rubocop offense in `context_summary.rb` autocorrected to use `Array#intersect?`. No behavior change.
19
+
20
+ ### Added
21
+
22
+ - **PathResolver architectural documentation** (#90/#91) — class-level docblock documents PathResolver's intentional role as a shared utility (11 introspector callers, high betweenness centrality). Prevents false "god class" flags from future graph analyses.
23
+ - **PathResolver edge-case tests** (#90/#91) — 6 new specs covering the private `SafeRelativePath` (backslash normalization, Windows path rejection, empty path rejection) and `SafeJoin` (valid joins, traversal escape prevention) helper classes.
24
+
25
+ ## [3.5.2]
26
+
27
+ ### Security
28
+
29
+ - **HTTP MCP unauthenticated boot warning** (#60/#81) — the standalone HTTP MCP server now prints a one-time stderr warning when it boots in a non-production environment without an authentication strategy, making the default open behavior visible.
30
+ - **Pluggable / distributed rate limiting** (#69/#80) — `config.mcp.rate_limiter` accepts any object implementing `allow?(ip)` or `call(ip)`, enabling shared backends such as Redis or `Rails.cache`. A built-in `Mcp::CacheRateLimiter` is provided for multi-process Puma deployments.
31
+ - **Skill pack lockfile verification** (#65/#84) — `config/rails_ai_bridge/directory.lock` records the expected git commit SHA for every remote skill pack. `PackResolver` compares the cloned HEAD against the lockfile and fails closed on mismatch. Generate or update the lockfile with `rails ai:registry:lockfile`. Verification mode is configurable via `config.registry.lockfile_verification` (`:strict`, `:warn`, `:disabled`).
32
+ - **Security documentation** (#61/#62/#82/#83) — added distributed rate-limiting guidance, a stdio transport threat model, and operational hardening recommendations.
33
+
34
+ ### Added
35
+
36
+ - **CORS support for HTTP MCP** (#63/#76) — `config.mcp.cors_origins` controls `Access-Control-Allow-Origin` headers for the MCP HTTP endpoint; `['*']` or a list of exact origins is supported.
37
+ - **JSON output for MCP tools** (#68/#77) — `rails_get_routes` and `rails_get_model_details` now accept `format: 'json'` for programmatic clients.
38
+ - **`authorize` lambda logging** (#64/#75) — explicit denies and lambda exceptions on the HTTP MCP path are now logged and emitted through `Mcp::HttpStructuredLog`.
39
+ - **`bundler-audit` CI job** (#74/#78) — the GitHub Actions workflow now runs `bundle-audit update && bundle-audit check` to catch known vulnerable dependencies.
40
+ - **MCP tool result caching** (#71/#79) — opt-in TTL-based cache keyed by tool name + SHA256 fingerprint of arguments. Enable with `config.mcp.tool_result_cache_ttl` (default `0`).
41
+ - **ActiveSupport::Notifications hooks** (#72/#85) — emits `rails_ai_bridge.tool.call`, `rails_ai_bridge.tool.result_cache_hit/miss`, `rails_ai_bridge.auth.success/failure`, and `rails_ai_bridge.rate_limit.hit` events.
42
+ - **Rails 8.1+ introspection signals** (#73/#86) — `GemRegistry` now recognizes `mission_control-jobs`; `ConfigIntrospector` reports `queue_adapter` and `cable_adapter`; `AuthIntrospector` surfaces Rails 8 generator patterns (`authentication_concern`, `generates_token_for`, `normalizes`).
43
+
44
+ ### Changed
45
+
46
+ - **Summary-first defaults** (#70/#87) — `rails_get_schema` and `rails_get_model_details` now default to `detail: 'summary'` when listing, reducing the chance of oversized tool responses. Callers can still opt into `standard` or `full` and use filters for specific tables/models.
47
+
48
+ ### Tests
49
+
50
+ - **Total: 2157 examples, 0 failures, 94.34% line coverage**.
51
+
8
52
  ## [3.5.1]
9
53
 
10
54
  ### Security
data/README.md CHANGED
@@ -780,7 +780,7 @@ Bug reports and pull requests: [github.com/igmarin/rails-ai-bridge/issues](https
780
780
 
781
781
  ## Acknowledgments & Origins
782
782
 
783
- This gem ships as **rails-ai-bridge** (Ruby **`RailsAiBridge`**, version **3.1.0**). Earlier iterations of the same codebase were distributed as `rails-ai-context`.
783
+ This gem ships as **rails-ai-bridge** (Ruby **`RailsAiBridge`**, version **3.6.0**). Earlier iterations of the same codebase were distributed as `rails-ai-context`.
784
784
 
785
785
  RailsMCP evolved from
786
786
  [crisnahine/rails-ai-context](https://github.com/crisnahine/rails-ai-context),
data/SECURITY.md CHANGED
@@ -85,6 +85,7 @@ see **[docs/mcp-security.md](docs/mcp-security.md)**.
85
85
  ## Operational Security Guidance
86
86
 
87
87
  - Prefer **stdio** transport for local development and AI-assisted editing.
88
+ See [docs/mcp-security.md](docs/mcp-security.md) for the stdio threat model.
88
89
  - If you enable HTTP transport, keep it bound to `127.0.0.1` unless you add your
89
90
  own network isolation and authentication controls.
90
91
  - Do **not** expose `auto_mount` on public or shared production surfaces without an explicit threat model review.
data/UPGRADING.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Upgrading rails-ai-bridge
2
2
 
3
+ ## Upgrading from 3.5.x to 3.6.0
4
+
5
+ **One action required if you are pinned to an older `rubydex`:**
6
+
7
+ The `rubydex` gem constraint was tightened from `~> 0.2.4` to `~> 0.2.9`. If your
8
+ lockfile resolves to rubydex 0.2.4–0.2.8, update it after upgrading:
9
+
10
+ ```bash
11
+ bundle update rubydex
12
+ ```
13
+
14
+ No other configuration or code changes are required. The `mcp` lower bound also
15
+ moved from `>= 0.10` to `>= 0.25`, but if you were already on a recent version
16
+ (no earlier than 0.25) this is a no-op.
17
+
18
+ ---
19
+
3
20
  ## Upgrading from 1.x to 2.x
4
21
 
5
22
  **No configuration changes required.** Every `config.*` attribute from 1.x is still available — `Configuration` now delegates to focused sub-objects but exposes the same flat DSL. See `CHANGELOG.md` for the full list of internal changes.
data/docs/mcp-security.md CHANGED
@@ -10,10 +10,41 @@ A valid Bearer token (or JWT accepted by your decoder) grants access to read-onl
10
10
 
11
11
  By default, HTTP MCP allows anonymous access when no auth strategy is configured (backward compatible for local development). For servers reachable beyond localhost, set `config.mcp.require_http_auth = true` so unconfigured deployments return `401`, or configure `http_mcp_token`, `mcp_token_resolver`, or `mcp_jwt_decoder`. In production, also use `validate_http_mcp_server_in_production!` / `require_auth_in_production` as documented in the main README.
12
12
 
13
+ In non-production environments, the standalone HTTP MCP server prints a one-time stderr warning when it starts without authentication to make the default behavior visible.
14
+
13
15
  ## Rate limiting and proxies
14
16
 
15
17
  Built-in rate limiting keys off the Rack request IP. Behind reverse proxies, configure Rails `trusted_proxies` so `request.ip` reflects the real client; otherwise limits may apply to the wrong address or be bypassed.
16
18
 
19
+ ### Single-process deployments
20
+
21
+ The default in-memory limiter works for one Puma worker or a single-process server. Configure the ceiling and window via `config.mcp.rate_limit_max_requests` and `config.mcp.rate_limit_window_seconds`.
22
+
23
+ ### Multi-process or multi-host deployments
24
+
25
+ The default in-memory limiter is not shared across Puma workers or hosts. For distributed deployments, plug in `RailsAiBridge::Mcp::CacheRateLimiter`, which uses `Rails.cache` (Redis, Memcached, etc.) as the shared counter backend:
26
+
27
+ ```ruby
28
+ RailsAiBridge.configure do |config|
29
+ config.mcp.rate_limiter = RailsAiBridge::Mcp::CacheRateLimiter.new(
30
+ max_requests: 300,
31
+ window_seconds: 60,
32
+ cache: Rails.cache,
33
+ key_prefix: "rab:rl"
34
+ )
35
+ end
36
+ ```
37
+
38
+ Alternatively, use `Rack::Attack` in front of the MCP endpoint for centralized, proxy-aware throttling:
39
+
40
+ ```ruby
41
+ class Rack::Attack
42
+ throttle("mcp/ip", limit: 300, period: 60) do |request|
43
+ request.ip if request.path == "/mcp"
44
+ end
45
+ end
46
+ ```
47
+
17
48
  ## Optional authorization after auth
18
49
 
19
50
  `config.mcp.authorize` can return false to issue `403` for otherwise valid tokens (e.g. tenant or role checks).
@@ -22,6 +53,21 @@ Built-in rate limiting keys off the Rack request IP. Behind reverse proxies, con
22
53
 
23
54
  The stdio MCP server has no Bearer layer; anyone who can run the process can use the tools. Use isolated users or containers if multiple tenants share a host.
24
55
 
56
+ ### Threat model
57
+
58
+ | Assumption | Implication |
59
+ |------------|-------------|
60
+ | The host operating system controls who can execute `rails ai:serve`. | Anyone with shell access to the Rails app can invoke every `rails_*` MCP tool and read the returned context. |
61
+ | The stdio transport is local to the process. | Network attackers cannot reach stdio directly, but a compromised local account or shared development container bypasses this boundary. |
62
+ | AI clients run with the same privileges as the user who launched them. | A malicious or misconfigured client can exfiltrate schema, routes, source code, and any file the user can read. |
63
+
64
+ ### Recommendations
65
+
66
+ - Run MCP-enabled Rails processes under dedicated, least-privilege OS accounts.
67
+ - In shared development containers or CI runners, avoid mixing users who should not see each other's application context.
68
+ - Do not mount sensitive credentials files or SSH keys into containers that run the MCP server unless those tools genuinely need them.
69
+ - Prefer HTTP MCP with authentication when multiple users or services share a host and need access controls beyond the OS.
70
+
25
71
  ## See also
26
72
 
27
73
  - [SECURITY.md](../SECURITY.md) — reporting vulnerabilities and design summary
@@ -154,6 +154,41 @@ When `ref` is set, rails-ai-bridge runs `git checkout <ref>` after cloning or pu
154
154
 
155
155
  ---
156
156
 
157
+ ## Lockfile verification
158
+
159
+ A lockfile records the exact git commit SHA of every remote pack that was resolved the last time the registry was built. On subsequent loads, rails-ai-bridge compares the locked SHA against the actual HEAD of the cloned repository and fails closed on mismatch.
160
+
161
+ This protects against:
162
+
163
+ - A compromised pack repository pushing unexpected content
164
+ - Accidental drift between environments (development vs. production)
165
+ - Force-pushes or tag rewrites changing the content under a stable ref name
166
+
167
+ ### Generate the lockfile
168
+
169
+ ```bash
170
+ rails ai:registry:lockfile
171
+ ```
172
+
173
+ The file is written to `config/rails_ai_bridge/directory.lock` by default. Commit it alongside your manifest.
174
+
175
+ ### Configuration
176
+
177
+ ```ruby
178
+ RailsAiBridge.configure do |config|
179
+ config.registry.lockfile_path = "config/rails_ai_bridge/directory.lock"
180
+ config.registry.lockfile_verification = :strict # :strict (default), :warn, or :disabled
181
+ end
182
+ ```
183
+
184
+ - `:strict` — raise `ResolutionError` when the resolved commit does not match the lockfile.
185
+ - `:warn` — log a stderr warning and continue loading.
186
+ - `:disabled` — skip verification entirely.
187
+
188
+ When no lockfile exists, verification is silently skipped so existing apps keep working.
189
+
190
+ ---
191
+
157
192
  ## The `directory.json` format
158
193
 
159
194
  Each skill pack repository should have a `directory.json` at its root. Here is an annotated example:
@@ -344,6 +379,7 @@ initializer changes take effect immediately.
344
379
  | Stale skills after pack update | Resolver cache is warm or pull TTL has not elapsed | Run `rails ai:skills:clear_cache` to force a re-clone on the next build |
345
380
  | Pack is not re-fetched even after `resolver_ttl` expired | Pack was cloned recently — `git_pull_ttl` (24 h) is still fresh | Run `rails ai:skills:clear_cache` or set `git_pull_ttl: 0` temporarily |
346
381
  | `git checkout <ref>` failed | Invalid ref or detached HEAD issue | Verify the `ref` value matches a branch, tag, or SHA in the remote repo |
382
+ | `Lockfile mismatch for pack '…'` | Resolved pack commit differs from `directory.lock` | Run `rails ai:registry:lockfile` to update the lockfile after reviewing the pack changes |
347
383
  | Git operation hangs / server request times out | Slow or unreachable remote | Reduce `git_timeout` to fail faster; check network connectivity to the remote |
348
384
  | Warning: "Pack '…' depends on '…' which is not in the active pack set" | A loaded pack has an unsatisfied `depends_on` entry | Add the missing pack name to `always_loaded` or `skill_packs` in your manifest |
349
385
 
@@ -358,4 +394,5 @@ initializer changes take effect immediately.
358
394
  - **Timeout protection**: all git operations are bounded by `git_timeout` (default 30 s) so a slow remote cannot block the calling thread indefinitely.
359
395
  - **Cache key sanitization**: local cache directory names are derived from a sanitized source string plus a SHA256 hash suffix to prevent filesystem collisions.
360
396
  - **Stable local pack names**: local registry pack names use a SHA256 digest of the directory path, so reordering `local_registry_paths` cannot silently shift pack identities.
397
+ - **Lockfile verification**: a `directory.lock` records the expected commit SHA for each remote pack. The resolver fails closed when the cloned commit does not match, preventing a compromised or unexpectedly modified pack from injecting instructions.
361
398
  - **Local path trust**: local paths are used directly without git operations. The path traversal guard in the Resolver still applies to file reads within a local pack.
@@ -185,9 +185,19 @@ module RailsAiBridge
185
185
  # config.http_mcp_token = "generate-a-long-random-secret"
186
186
  # # ENV["RAILS_AI_BRIDGE_MCP_TOKEN"] takes precedence when set
187
187
  #
188
+ # Require authentication on every HTTP MCP request. When true, requests
189
+ # return 401 unless one of the auth mechanisms above is configured.
190
+ # Default is false for backward compatibility with local development.
191
+ # config.mcp.require_http_auth = true
192
+ #
188
193
  # Timing-safe token comparison is built in, but add rate limiting too
189
194
  # (e.g. Rack::Attack throttle on config.http_path) to prevent brute-force.
190
195
  #
196
+ # CORS for browser-based AI clients connecting over SSE.
197
+ # Default is nil (no CORS headers). Set to ['*'] to allow any origin,
198
+ # or to a list of exact origins such as ['https://app.example.com'].
199
+ # config.mcp.cors_origins = ['https://app.example.com']
200
+ #
191
201
  # config.auto_mount = false
192
202
  # config.allow_auto_mount_in_production = false
193
203
  # config.http_path = "/mcp"
@@ -68,6 +68,30 @@ module RailsAiBridge
68
68
  # @return [Boolean]
69
69
  attr_accessor :require_http_auth
70
70
 
71
+ # Allowed origins for CORS on the HTTP MCP endpoint.
72
+ # +nil+ or empty array disables CORS headers (default). Pass +['*']+ to allow any origin,
73
+ # or a list of exact origins such as +['https://app.example.com']+.
74
+ # @return [Array<String>, nil]
75
+ attr_accessor :cors_origins
76
+
77
+ # TTL in seconds for MCP tool result caching by argument fingerprint.
78
+ # +0+ or negative disables caching. Default +0+ to keep existing behavior.
79
+ # @return [Integer]
80
+ attr_accessor :tool_result_cache_ttl
81
+
82
+ # Optional custom rate limiter for MCP HTTP requests.
83
+ #
84
+ # When set, it takes precedence over the in-memory {Mcp::HttpRateLimiter}.
85
+ # The object must respond to +allow?(ip)+ (preferred) or +call(ip)+ and return a truthy/falsey value.
86
+ # This enables distributed backends such as Redis, Rails.cache, or an external service.
87
+ #
88
+ # @return [#allow?, #call, nil]
89
+ attr_accessor :rate_limiter
90
+
91
+ # Key prefix used by the optional {Mcp::CacheRateLimiter} distributed rate limiter.
92
+ # @return [String]
93
+ attr_accessor :rate_limiter_key_prefix
94
+
71
95
  def initialize
72
96
  @mode = :hybrid
73
97
  @security_profile = :balanced
@@ -77,6 +101,15 @@ module RailsAiBridge
77
101
  @authorize = nil
78
102
  @require_auth_in_production = false
79
103
  @require_http_auth = false
104
+ @cors_origins = nil
105
+ @tool_result_cache_ttl = 0
106
+ @rate_limiter = nil
107
+ @rate_limiter_key_prefix = 'rab:rl'
108
+ end
109
+
110
+ # @return [Boolean] whether tool call results should be cached
111
+ def tool_result_cache_enabled?
112
+ tool_result_cache_ttl.to_i.positive?
80
113
  end
81
114
 
82
115
  # Effective rate-limit ceiling for {HttpTransportApp} (+0+ means disabled).
@@ -35,6 +35,13 @@ module RailsAiBridge
35
35
  # Prevents a slow or unreachable remote from blocking the calling thread indefinitely.
36
36
  attr_reader :git_timeout
37
37
 
38
+ # @return [String, nil] path to the skill pack lockfile. nil disables lockfile verification.
39
+ attr_accessor :lockfile_path
40
+
41
+ # @return [Symbol] how to behave when the lockfile differs from the resolved pack:
42
+ # :strict (default) raises, :warn logs but proceeds, :disabled skips verification.
43
+ attr_accessor :lockfile_verification
44
+
38
45
  # Sets the git pull TTL.
39
46
  #
40
47
  # Coerces the value to a non-negative integer; raises +ArgumentError+ for
@@ -93,6 +100,8 @@ module RailsAiBridge
93
100
  @resolver_ttl = 1800
94
101
  @git_pull_ttl = 86_400
95
102
  @git_timeout = 30
103
+ @lockfile_path = 'config/rails_ai_bridge/directory.lock'
104
+ @lockfile_verification = :strict
96
105
  end
97
106
  end
98
107
  end
@@ -114,7 +114,16 @@ module RailsAiBridge
114
114
  :rate_limit_max_requests, :rate_limit_max_requests=,
115
115
  :rate_limit_window_seconds, :rate_limit_window_seconds=,
116
116
  :http_log_json, :http_log_json=,
117
- :require_http_auth, :require_http_auth=
117
+ :require_http_auth, :require_http_auth=,
118
+ :tool_result_cache_ttl, :tool_result_cache_ttl=,
119
+ :tool_result_cache_enabled?,
120
+ :rate_limiter, :rate_limiter=,
121
+ :rate_limiter_key_prefix, :rate_limiter_key_prefix=
122
+
123
+ # -- Config::Registry -------------------------------------------------------
124
+ def_delegators :@registry,
125
+ :lockfile_path, :lockfile_path=,
126
+ :lockfile_verification, :lockfile_verification=
118
127
 
119
128
  # -- Config::Rubydex --------------------------------------------------------
120
129
  def_delegators :@rubydex,
@@ -12,15 +12,17 @@ module RailsAiBridge
12
12
  mcp_cfg = RailsAiBridge.configuration.mcp
13
13
  max_reqs = mcp_cfg.effective_http_rate_limit_max_requests
14
14
  window_sec = mcp_cfg.effective_http_rate_limit_window_seconds
15
- limiter = if max_reqs.positive?
16
- Mcp::HttpRateLimiter.new(max_requests: max_reqs,
17
- window_seconds: window_sec)
18
- end
15
+ limiter = mcp_cfg.rate_limiter || build_default_rate_limiter(max_requests: max_reqs,
16
+ window_seconds: window_sec)
17
+ cors_origins = Array(mcp_cfg.cors_origins).reject(&:empty?)
19
18
 
20
19
  lambda do |env|
21
20
  return [404, { 'Content-Type' => 'application/json' }, ['{"error":"Not found"}']] unless [path, "#{path}/"].include?(env['PATH_INFO'])
22
21
 
23
22
  request = Rack::Request.new(env)
23
+ cors_headers = build_cors_headers(request.get_header('HTTP_ORIGIN'), cors_origins)
24
+
25
+ return [204, cors_headers, ['']] if request.request_method == 'OPTIONS' && cors_headers
24
26
 
25
27
  if mcp_cfg.require_http_auth && !Mcp::Authenticator.any_configured?
26
28
  Mcp::HttpStructuredLog.emit(request: request, event: :unauthorized, http_status: 401)
@@ -35,19 +37,23 @@ module RailsAiBridge
35
37
 
36
38
  authorize = RailsAiBridge.configuration.mcp.authorize
37
39
  if authorize
38
- authorized = begin
39
- authorize.call(auth_result.context, request)
40
+ begin
41
+ authorized = authorize.call(auth_result.context, request)
40
42
  rescue StandardError => error
41
- Rails.logger.error("rails_ai_bridge: authorize lambda raised #{error.class}: #{error.message}") if defined?(Rails)
42
- false
43
+ Rails.logger.error("rails_ai_bridge: authorize lambda raised #{error.class}: #{error.message}") if defined?(Rails) && Rails.logger
44
+ Mcp::HttpStructuredLog.emit(request: request, event: :forbidden, http_status: 403)
45
+ return [403, { 'Content-Type' => 'application/json' }, ['{"error":"Forbidden"}']]
43
46
  end
47
+
44
48
  unless authorized
49
+ Rails.logger.warn("rails_ai_bridge: authorize lambda denied access for #{request.ip} at #{request.path}") if defined?(Rails) && Rails.logger
45
50
  Mcp::HttpStructuredLog.emit(request: request, event: :forbidden, http_status: 403)
46
51
  return [403, { 'Content-Type' => 'application/json' }, ['{"error":"Forbidden"}']]
47
52
  end
48
53
  end
49
54
 
50
- if limiter && !limiter.allow?(request.ip)
55
+ if limiter && !rate_limiter_allow?(limiter, request)
56
+ Instrumentation.instrument('rate_limit.hit', ip: request.ip, path: request.path)
51
57
  Mcp::HttpStructuredLog.emit(request: request, event: :rate_limited, http_status: 429)
52
58
  return [429, { 'Content-Type' => 'application/json', 'Retry-After' => window_sec.to_s },
53
59
  ['{"error":"Too many requests"}']]
@@ -55,9 +61,51 @@ module RailsAiBridge
55
61
 
56
62
  response = transport.handle_request(request)
57
63
  Mcp::HttpStructuredLog.emit(request: request, event: :handled, http_status: response.first)
64
+ response[1].merge!(cors_headers) if cors_headers
58
65
  response
59
66
  end
60
67
  end
68
+
69
+ private
70
+
71
+ def build_default_rate_limiter(max_requests:, window_seconds:)
72
+ return nil unless max_requests.positive?
73
+
74
+ Mcp::HttpRateLimiter.new(max_requests: max_requests, window_seconds: window_seconds)
75
+ end
76
+
77
+ # Asks a configured rate limiter whether a request may proceed.
78
+ # Supports objects that expose +allow?(ip)+ (preferred) or +call(ip)+.
79
+ #
80
+ # @param limiter [#allow?, #call]
81
+ # @param request [Rack::Request]
82
+ # @return [Boolean]
83
+ def rate_limiter_allow?(limiter, request)
84
+ if limiter.respond_to?(:allow?)
85
+ limiter.allow?(request.ip)
86
+ elsif limiter.respond_to?(:call)
87
+ limiter.call(request.ip)
88
+ else
89
+ true
90
+ end
91
+ end
92
+
93
+ # Builds CORS response headers when the request origin is allowed.
94
+ #
95
+ # @param origin [String, nil] value of the Origin header
96
+ # @param cors_origins [Array<String>] configured allowed origins
97
+ # @return [Hash{String => String}, nil] headers to add, or +nil+ when CORS is disabled/unmatched
98
+ def build_cors_headers(origin, cors_origins)
99
+ return nil if cors_origins.empty?
100
+ return nil if origin.blank?
101
+ return nil unless cors_origins.include?('*') || cors_origins.include?(origin)
102
+
103
+ {
104
+ 'Access-Control-Allow-Origin' => origin,
105
+ 'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS',
106
+ 'Access-Control-Allow-Headers' => 'Authorization, Content-Type'
107
+ }
108
+ end
61
109
  end
62
110
  end
63
111
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'delegate'
4
+
5
+ module RailsAiBridge
6
+ # Thin wrapper around ActiveSupport::Notifications for rails-ai-bridge events.
7
+ #
8
+ # Events are namespaced under +rails_ai_bridge+. If ActiveSupport::Notifications
9
+ # is not available, the helper yields without instrumentation so the code can be
10
+ # used outside of Rails without a hard dependency.
11
+ module Instrumentation
12
+ NAMESPACE = 'rails_ai_bridge'
13
+
14
+ # Emits an instrumentation event.
15
+ #
16
+ # @param event [String] event name suffix (e.g. +tool.call+)
17
+ # @param payload [Hash] extra payload attached to the event
18
+ # @yield optional block to wrap; the block result is returned
19
+ # @return [Object] the block result, or nil when no block is given
20
+ def self.instrument(event, payload = {})
21
+ return yield unless defined?(ActiveSupport::Notifications) && ActiveSupport::Notifications
22
+
23
+ ActiveSupport::Notifications.instrument("#{NAMESPACE}.#{event}", payload) do
24
+ yield if block_given?
25
+ end
26
+ end
27
+
28
+ # Wraps an MCP tool class so every call emits a +rails_ai_bridge.tool.call+
29
+ # event. Caching, when enabled, is handled by the inner wrapper so cache
30
+ # hit/miss events stay separate from the invocation event.
31
+ class InstrumentedTool < SimpleDelegator
32
+ def initialize(tool_class)
33
+ super
34
+ @tool_class = tool_class
35
+ end
36
+
37
+ # @param server_context [Object, nil]
38
+ # @param arguments [Hash]
39
+ # @return [MCP::Tool::Response]
40
+ def call(server_context: nil, **arguments)
41
+ Instrumentation.instrument('tool.call', tool_name: @tool_class.tool_name, arguments: arguments) do
42
+ @tool_class.call(server_context: server_context, **arguments)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -39,8 +39,8 @@ module RailsAiBridge
39
39
  devise_models = scan_models_for(/devise\s+(.+)$/)
40
40
  auth[:devise] = devise_models if devise_models.any?
41
41
 
42
- # Rails 8 built-in auth
43
- auth[:rails_auth] = true if model_file_exists?('session.rb') && model_file_exists?('current.rb')
42
+ # Rails 8 built-in auth generator patterns
43
+ auth[:rails_auth] = detect_rails_auth_details if model_file_exists?('session.rb') && model_file_exists?('current.rb')
44
44
 
45
45
  # has_secure_password
46
46
  secure_pw = scan_models_for(/has_secure_password/)
@@ -49,6 +49,20 @@ module RailsAiBridge
49
49
  auth
50
50
  end
51
51
 
52
+ def detect_rails_auth_details
53
+ details = {
54
+ authentication_concern: file_exists?('app/controllers/concerns/authentication.rb')
55
+ }
56
+
57
+ token_for = scan_models_for(/generates_token_for\s+:(\w+)/)
58
+ details[:token_for] = token_for.map { |entry| { model: entry[:model], tokens: entry[:matches] } } if token_for.any?
59
+
60
+ normalized = scan_models_for(/normalizes\s+:(\w+)/)
61
+ details[:normalized_attributes] = normalized.map { |entry| { model: entry[:model], attrs: entry[:matches] } } if normalized.any?
62
+
63
+ details
64
+ end
65
+
52
66
  def detect_authorization
53
67
  authz = {}
54
68
 
@@ -20,6 +20,8 @@ module RailsAiBridge
20
20
  result = {
21
21
  cache_store: detect_cache_store,
22
22
  session_store: detect_session_store,
23
+ queue_adapter: detect_queue_adapter,
24
+ cable_adapter: detect_cable_adapter,
23
25
  timezone: app.config.time_zone.to_s,
24
26
  middleware_stack: extract_middleware,
25
27
  initializers: extract_initializers,
@@ -54,6 +56,24 @@ module RailsAiBridge
54
56
  'unknown'
55
57
  end
56
58
 
59
+ def detect_queue_adapter
60
+ return 'unknown' unless app.config.respond_to?(:active_job)
61
+
62
+ adapter = app.config.active_job&.queue_adapter
63
+ adapter.is_a?(Symbol) ? adapter.to_s : adapter.class.name
64
+ rescue StandardError
65
+ 'unknown'
66
+ end
67
+
68
+ def detect_cable_adapter
69
+ return 'unknown' unless app.config.respond_to?(:action_cable)
70
+
71
+ adapter = app.config.action_cable&.adapter
72
+ adapter.is_a?(Symbol) ? adapter.to_s : adapter.class.name
73
+ rescue StandardError
74
+ 'unknown'
75
+ end
76
+
57
77
  def extract_middleware
58
78
  app.middleware.map { |m| m.name || m.klass.to_s }.uniq
59
79
  rescue StandardError
@@ -20,6 +20,7 @@ module RailsAiBridge
20
20
  'sidekiq' => { category: :jobs, note: 'Background jobs via Sidekiq. Check config/sidekiq.yml.' },
21
21
  'good_job' => { category: :jobs, note: 'Background jobs via GoodJob (Postgres-backed).' },
22
22
  'solid_queue' => { category: :jobs, note: 'Background jobs via SolidQueue (Rails 8 default).' },
23
+ 'mission_control-jobs' => { category: :jobs, note: 'Web UI for SolidQueue / Active Job monitoring.' },
23
24
  'delayed_job' => { category: :jobs, note: 'Background jobs via DelayedJob.' },
24
25
  'resque' => { category: :jobs, note: 'Background jobs via Resque (Redis-backed).' },
25
26
  'sneakers' => { category: :jobs, note: 'Background jobs via Sneakers (RabbitMQ).' },
@@ -51,7 +51,13 @@ module RailsAiBridge
51
51
  strategy = resolve_strategy
52
52
  return AuthResult.ok(nil) if strategy.nil?
53
53
 
54
- strategy.authenticate(request)
54
+ strategy.authenticate(request).tap do |result|
55
+ if result.success?
56
+ Instrumentation.instrument('auth.success', strategy: strategy_name(strategy), ip: request.ip)
57
+ else
58
+ Instrumentation.instrument('auth.failure', strategy: strategy_name(strategy), ip: request.ip, error: result.error)
59
+ end
60
+ end
55
61
  end
56
62
 
57
63
  # Returns +true+ when any auth mechanism is configured — static token,
@@ -80,6 +86,14 @@ module RailsAiBridge
80
86
  ]
81
87
  end
82
88
 
89
+ # Human-readable strategy name for observability payloads.
90
+ #
91
+ # @param strategy [Auth::BaseStrategy]
92
+ # @return [String]
93
+ def strategy_name(strategy)
94
+ strategy.class.name&.split('::')&.last || 'unknown'
95
+ end
96
+
83
97
  private
84
98
 
85
99
  # Selects the appropriate auth strategy from the current configuration.