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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -0
- data/README.md +1 -1
- data/SECURITY.md +1 -0
- data/UPGRADING.md +17 -0
- data/docs/mcp-security.md +46 -0
- data/docs/skill-registry-guide.md +37 -0
- data/lib/generators/rails_ai_bridge/install/install_generator.rb +10 -0
- data/lib/rails_ai_bridge/config/mcp.rb +33 -0
- data/lib/rails_ai_bridge/config/registry.rb +9 -0
- data/lib/rails_ai_bridge/configuration.rb +10 -1
- data/lib/rails_ai_bridge/http_transport_app.rb +57 -9
- data/lib/rails_ai_bridge/instrumentation.rb +47 -0
- data/lib/rails_ai_bridge/introspectors/auth_introspector.rb +16 -2
- data/lib/rails_ai_bridge/introspectors/config_introspector.rb +20 -0
- data/lib/rails_ai_bridge/introspectors/gem_registry.rb +1 -0
- data/lib/rails_ai_bridge/mcp/authenticator.rb +15 -1
- data/lib/rails_ai_bridge/mcp/cache_rate_limiter.rb +68 -0
- data/lib/rails_ai_bridge/path_resolver.rb +21 -0
- data/lib/rails_ai_bridge/registry/lockfile.rb +109 -0
- data/lib/rails_ai_bridge/registry/pack_resolver.rb +39 -2
- data/lib/rails_ai_bridge/registry/skill_source_resolver.rb +34 -0
- data/lib/rails_ai_bridge/registry.rb +18 -0
- data/lib/rails_ai_bridge/serializers/context_summary.rb +1 -1
- data/lib/rails_ai_bridge/server.rb +25 -2
- data/lib/rails_ai_bridge/tasks/rails_ai_bridge.rake +13 -0
- data/lib/rails_ai_bridge/tool_result_cache.rb +138 -0
- data/lib/rails_ai_bridge/tools/get_model_details.rb +23 -5
- data/lib/rails_ai_bridge/tools/get_routes.rb +20 -3
- data/lib/rails_ai_bridge/tools/get_schema.rb +2 -2
- data/lib/rails_ai_bridge/version.rb +1 -1
- data/rails-ai-bridge.gemspec +2 -2
- metadata +9 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b375ce08baa45d08d3c20625d27280862175bb022677e1f5c5ae90d1eb7a1493
|
|
4
|
+
data.tar.gz: 7d8de9d9b1642b447c03cc3b36820d2008dee648a475ebffaac038a9f081a178
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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 =
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 && !
|
|
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] =
|
|
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.
|