rails-ai-bridge 3.5.1 → 3.5.2
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 +27 -0
- data/SECURITY.md +1 -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/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/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
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 87f66a2c3254515e628ba04cac7fcda527bda86254df738f3384f28ccad5fcdf
|
|
4
|
+
data.tar.gz: 6bc9b66810b8c5bedd1526a3683b13530a46c1c5b61ba7db89288544d61bce20
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ae624883e2d08b61bd76d178b52d7067aefd611a1fc6e6f760b7f07361b7acbe877b2ce3f5984b2b6c9d0c74b7376c522cc8065fdf25a8cc8c78660a95583c18
|
|
7
|
+
data.tar.gz: 8cc300bb5f6e4d9669e8de9313eca2c4e5cd0c4a7ce336c2cec7bd5c69665df2580d3864536aef092cc22c16ca775909d26c40c372b5bc065577f567836dc149
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ 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.5.2]
|
|
9
|
+
|
|
10
|
+
### Security
|
|
11
|
+
|
|
12
|
+
- **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.
|
|
13
|
+
- **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.
|
|
14
|
+
- **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`).
|
|
15
|
+
- **Security documentation** (#61/#62/#82/#83) — added distributed rate-limiting guidance, a stdio transport threat model, and operational hardening recommendations.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **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.
|
|
20
|
+
- **JSON output for MCP tools** (#68/#77) — `rails_get_routes` and `rails_get_model_details` now accept `format: 'json'` for programmatic clients.
|
|
21
|
+
- **`authorize` lambda logging** (#64/#75) — explicit denies and lambda exceptions on the HTTP MCP path are now logged and emitted through `Mcp::HttpStructuredLog`.
|
|
22
|
+
- **`bundler-audit` CI job** (#74/#78) — the GitHub Actions workflow now runs `bundle-audit update && bundle-audit check` to catch known vulnerable dependencies.
|
|
23
|
+
- **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`).
|
|
24
|
+
- **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.
|
|
25
|
+
- **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`).
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- **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.
|
|
30
|
+
|
|
31
|
+
### Tests
|
|
32
|
+
|
|
33
|
+
- **Total: 2157 examples, 0 failures, 94.34% line coverage**.
|
|
34
|
+
|
|
8
35
|
## [3.5.1]
|
|
9
36
|
|
|
10
37
|
### Security
|
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/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.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiBridge
|
|
4
|
+
module Mcp
|
|
5
|
+
# Distributed, fixed-window rate limiter backed by +Rails.cache+ (or any
|
|
6
|
+
# +ActiveSupport::Cache::Store+). Use it when the MCP HTTP endpoint runs
|
|
7
|
+
# behind multiple processes and the default in-memory limiter is insufficient.
|
|
8
|
+
#
|
|
9
|
+
# The limiter relies on the cache's +increment+ operation when available and
|
|
10
|
+
# falls back to read/write for stores that do not support atomic increments.
|
|
11
|
+
class CacheRateLimiter
|
|
12
|
+
# @param max_requests [Integer] allowed hits per window per client IP
|
|
13
|
+
# @param window_seconds [Integer] TTL for each client's counter
|
|
14
|
+
# @param cache [Object, nil] +ActiveSupport::Cache::Store+; defaults to +Rails.cache+
|
|
15
|
+
# @param key_prefix [String] prefix for cache keys
|
|
16
|
+
def initialize(max_requests:, window_seconds:, cache: nil, key_prefix: 'rab:rl')
|
|
17
|
+
@max_requests = max_requests
|
|
18
|
+
@window_seconds = window_seconds.to_i
|
|
19
|
+
@cache = cache
|
|
20
|
+
@key_prefix = key_prefix
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param ip [String, nil] client identifier (blank values share the +unknown+ bucket)
|
|
24
|
+
# @return [Boolean] +true+ if the request may proceed
|
|
25
|
+
def allow?(ip)
|
|
26
|
+
store = cache_store
|
|
27
|
+
return true unless store
|
|
28
|
+
|
|
29
|
+
key = cache_key(ip)
|
|
30
|
+
count = increment(store, key)
|
|
31
|
+
count <= @max_requests
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def cache_store
|
|
37
|
+
@cache || (defined?(Rails) && Rails.cache)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def cache_key(ip)
|
|
41
|
+
client = ip.to_s.presence || 'unknown'
|
|
42
|
+
"#{@key_prefix}:#{client}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def increment(store, key)
|
|
46
|
+
if store.respond_to?(:increment)
|
|
47
|
+
store.increment(key, 1, expires_in: @window_seconds) || fallback_increment(store, key)
|
|
48
|
+
else
|
|
49
|
+
fallback_increment(store, key)
|
|
50
|
+
end
|
|
51
|
+
rescue StandardError
|
|
52
|
+
fallback_increment(store, key)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def fallback_increment(store, key)
|
|
56
|
+
if store.respond_to?(:increment)
|
|
57
|
+
store.write(key, 0, expires_in: @window_seconds, unless_exist: true)
|
|
58
|
+
store.increment(key, 1) || (store.read(key).to_i + 1)
|
|
59
|
+
else
|
|
60
|
+
store.write(key, store.read(key).to_i + 1, expires_in: @window_seconds)
|
|
61
|
+
store.read(key).to_i
|
|
62
|
+
end
|
|
63
|
+
rescue StandardError
|
|
64
|
+
1
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module RailsAiBridge
|
|
7
|
+
module Registry
|
|
8
|
+
# Records and verifies expected git commit SHAs for skill packs.
|
|
9
|
+
#
|
|
10
|
+
# The lockfile is a JSON document keyed by pack name. Each entry stores the
|
|
11
|
+
# pack source, ref, and the resolved commit SHA. When verification is enabled,
|
|
12
|
+
# {PackResolver} compares the locked SHA against the actual HEAD of the cloned
|
|
13
|
+
# repository and fails closed on mismatch.
|
|
14
|
+
class Lockfile
|
|
15
|
+
# Represents one lockfile entry.
|
|
16
|
+
Entry = Data.define(:pack_name, :source, :ref, :commit_sha)
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
# Loads a lockfile from disk.
|
|
20
|
+
#
|
|
21
|
+
# @param path [String, nil] path to the lockfile; nil means no lockfile
|
|
22
|
+
# @return [Lockfile] empty lockfile when path is nil or the file does not exist
|
|
23
|
+
def load(path)
|
|
24
|
+
return new({}) if path.nil? || !File.exist?(path)
|
|
25
|
+
|
|
26
|
+
raw = File.read(path)
|
|
27
|
+
data = JSON.parse(raw)
|
|
28
|
+
entries = data.transform_values do |entry|
|
|
29
|
+
Entry.new(
|
|
30
|
+
pack_name: entry['pack_name'],
|
|
31
|
+
source: entry['source'],
|
|
32
|
+
ref: entry['ref'],
|
|
33
|
+
commit_sha: entry['commit_sha']
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
new(entries)
|
|
37
|
+
rescue JSON::ParserError => error
|
|
38
|
+
raise ArgumentError, "Invalid lockfile JSON at #{path}: #{error.message}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Generates lockfile entries by resolving every pack in the manifest.
|
|
42
|
+
#
|
|
43
|
+
# @param manifest [RegistryManifest]
|
|
44
|
+
# @param source_resolver [SkillSourceResolver]
|
|
45
|
+
# @return [Hash{String => Entry}]
|
|
46
|
+
def generate(manifest, source_resolver)
|
|
47
|
+
manifest.packs.each_with_object({}) do |(name, pack_def), entries|
|
|
48
|
+
base_path = source_resolver.resolve(pack_def.source, ref: pack_def.ref)
|
|
49
|
+
commit_sha = source_resolver.current_commit(base_path)
|
|
50
|
+
entries[name] = Entry.new(
|
|
51
|
+
pack_name: name,
|
|
52
|
+
source: pack_def.source,
|
|
53
|
+
ref: pack_def.ref,
|
|
54
|
+
commit_sha: commit_sha
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Writes a lockfile to disk for the given manifest.
|
|
60
|
+
#
|
|
61
|
+
# @param path [String]
|
|
62
|
+
# @param manifest [RegistryManifest]
|
|
63
|
+
# @param source_resolver [SkillSourceResolver]
|
|
64
|
+
# @return [void]
|
|
65
|
+
def write(path, manifest, source_resolver)
|
|
66
|
+
entries = generate(manifest, source_resolver)
|
|
67
|
+
new(entries).write(path)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @param entries [Hash{String => Entry}] map of pack name to lockfile entry
|
|
72
|
+
def initialize(entries)
|
|
73
|
+
@entries = entries.freeze
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
delegate :any?, to: :@entries
|
|
77
|
+
|
|
78
|
+
# @param pack_name [String]
|
|
79
|
+
# @return [Entry, nil]
|
|
80
|
+
def entry(pack_name)
|
|
81
|
+
@entries[pack_name]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Serializes the lockfile to JSON.
|
|
85
|
+
#
|
|
86
|
+
# @return [String]
|
|
87
|
+
def to_json(*)
|
|
88
|
+
data = @entries.transform_values do |entry|
|
|
89
|
+
{
|
|
90
|
+
'pack_name' => entry.pack_name,
|
|
91
|
+
'source' => entry.source,
|
|
92
|
+
'ref' => entry.ref,
|
|
93
|
+
'commit_sha' => entry.commit_sha
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
JSON.pretty_generate(data)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Writes the lockfile to disk.
|
|
100
|
+
#
|
|
101
|
+
# @param path [String]
|
|
102
|
+
# @return [void]
|
|
103
|
+
def write(path)
|
|
104
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
105
|
+
File.write(path, "#{to_json}\n")
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -22,15 +22,18 @@ module RailsAiBridge
|
|
|
22
22
|
#
|
|
23
23
|
# @param source_resolver [SkillSourceResolver] resolver for remote git sources
|
|
24
24
|
# @param pack_detector [Object] optional pack detector for testing (defaults to PackDetector)
|
|
25
|
-
|
|
25
|
+
# @param lockfile [Lockfile, nil] optional lockfile for pack verification
|
|
26
|
+
def initialize(source_resolver, pack_detector = PackDetector, lockfile = nil)
|
|
26
27
|
@source_resolver = source_resolver
|
|
27
28
|
@pack_detector = pack_detector
|
|
29
|
+
@lockfile = lockfile || default_lockfile
|
|
28
30
|
end
|
|
29
31
|
|
|
30
32
|
# Resolves active packs from the manifest and custom configuration.
|
|
31
33
|
#
|
|
32
34
|
# This will automatically resolve remote repositories if needed, load the tile
|
|
33
|
-
# manifest configurations,
|
|
35
|
+
# manifest configurations, merge local directory overrides, and verify pack
|
|
36
|
+
# commit SHAs against the configured lockfile.
|
|
34
37
|
#
|
|
35
38
|
# @param manifest [RegistryManifest] the registry manifest containing pack definitions
|
|
36
39
|
# @param explicit_packs [Array<String>, nil] optional list of pack names to load
|
|
@@ -47,6 +50,38 @@ module RailsAiBridge
|
|
|
47
50
|
|
|
48
51
|
private
|
|
49
52
|
|
|
53
|
+
def default_lockfile
|
|
54
|
+
Lockfile.load(RailsAiBridge.configuration.registry.lockfile_path)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Verifies the resolved pack commit SHA against the lockfile when one exists.
|
|
58
|
+
#
|
|
59
|
+
# Behavior is controlled by +config.registry.lockfile_verification+:
|
|
60
|
+
# :strict — raise on mismatch (default)
|
|
61
|
+
# :warn — log a warning and continue
|
|
62
|
+
# :disabled — skip verification entirely
|
|
63
|
+
#
|
|
64
|
+
# @param name [String] pack name
|
|
65
|
+
# @param base_path [String] local path to the resolved pack
|
|
66
|
+
# @return [void]
|
|
67
|
+
def verify_pack!(name, base_path)
|
|
68
|
+
return unless @lockfile&.any?
|
|
69
|
+
|
|
70
|
+
mode = RailsAiBridge.configuration.registry.lockfile_verification
|
|
71
|
+
return if mode == :disabled
|
|
72
|
+
|
|
73
|
+
entry = @lockfile.entry(name)
|
|
74
|
+
return unless entry
|
|
75
|
+
|
|
76
|
+
actual = @source_resolver.current_commit(base_path)
|
|
77
|
+
return if actual == entry.commit_sha
|
|
78
|
+
|
|
79
|
+
message = "Lockfile mismatch for pack '#{name}': expected #{entry.commit_sha}, got #{actual}"
|
|
80
|
+
return warn "[rails-ai-bridge] #{message}" if mode == :warn
|
|
81
|
+
|
|
82
|
+
raise SkillSourceResolver::ResolutionError, "#{message}. Run `rails ai:registry:lockfile` to update the lockfile."
|
|
83
|
+
end
|
|
84
|
+
|
|
50
85
|
# Gathers the set of pack names that should be loaded.
|
|
51
86
|
#
|
|
52
87
|
# Combines always_loaded packs with either explicit_packs or auto-detected framework packs.
|
|
@@ -105,6 +140,8 @@ module RailsAiBridge
|
|
|
105
140
|
raise SkillSourceResolver::ResolutionError, "Pack '#{name}' not defined in registry manifest" unless pack_def
|
|
106
141
|
|
|
107
142
|
base_path = @source_resolver.resolve(pack_def.source, ref: pack_def.ref)
|
|
143
|
+
verify_pack!(name, base_path)
|
|
144
|
+
|
|
108
145
|
tile_path = File.join(base_path, pack_def.tile)
|
|
109
146
|
|
|
110
147
|
unless File.exist?(tile_path)
|
|
@@ -39,6 +39,15 @@ module RailsAiBridge
|
|
|
39
39
|
def checkout_ref(_path, _ref)
|
|
40
40
|
raise NotImplementedError
|
|
41
41
|
end
|
|
42
|
+
|
|
43
|
+
# Returns the current HEAD commit SHA for the repository at +_path+.
|
|
44
|
+
#
|
|
45
|
+
# @param _path [String] path to the local repository
|
|
46
|
+
# @raise [StandardError] if the commit cannot be determined
|
|
47
|
+
# @return [String] 40-character hex commit SHA
|
|
48
|
+
def current_commit(_path)
|
|
49
|
+
raise NotImplementedError
|
|
50
|
+
end
|
|
42
51
|
end
|
|
43
52
|
|
|
44
53
|
# Default implementation of {GitRunner} using Open3 to spawn git subprocesses.
|
|
@@ -85,6 +94,20 @@ module RailsAiBridge
|
|
|
85
94
|
end
|
|
86
95
|
end
|
|
87
96
|
|
|
97
|
+
# Returns the current HEAD commit SHA for the repository at +path+.
|
|
98
|
+
#
|
|
99
|
+
# @param path [String] path to the local repository
|
|
100
|
+
# @raise [RuntimeError] if the git command fails or returns non-zero
|
|
101
|
+
# @return [String] 40-character hex commit SHA
|
|
102
|
+
def current_commit(path)
|
|
103
|
+
with_timeout('git rev-parse') do
|
|
104
|
+
stdout, stderr, status = Open3.capture3('git', 'rev-parse', 'HEAD', chdir: path) # nosemgrep: ruby.lang.security.dangerous-exec.dangerous-exec
|
|
105
|
+
fail_with_sanitized_error!('git rev-parse', stderr) unless status.success?
|
|
106
|
+
|
|
107
|
+
stdout.strip
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
88
111
|
# Checks out a specific git ref inside a local repository directory.
|
|
89
112
|
#
|
|
90
113
|
# @param path [String] path to the local repository
|
|
@@ -232,6 +255,17 @@ module RailsAiBridge
|
|
|
232
255
|
cache_path
|
|
233
256
|
end
|
|
234
257
|
|
|
258
|
+
# Returns the current HEAD commit SHA for the repository at +path+.
|
|
259
|
+
#
|
|
260
|
+
# @param path [String] path to the local repository
|
|
261
|
+
# @return [String] 40-character hex commit SHA
|
|
262
|
+
# @raise [ResolutionError] if the commit cannot be read
|
|
263
|
+
def current_commit(path)
|
|
264
|
+
@git_runner.current_commit(path)
|
|
265
|
+
rescue StandardError => error
|
|
266
|
+
raise ResolutionError, "failed to read current commit for pack at #{path}: #{error.message}"
|
|
267
|
+
end
|
|
268
|
+
|
|
235
269
|
private
|
|
236
270
|
|
|
237
271
|
# Returns true when no successful pull has been recorded for +cache_path+ within
|
|
@@ -18,6 +18,7 @@ require_relative 'registry/resolver'
|
|
|
18
18
|
require_relative 'registry/source_parser'
|
|
19
19
|
require_relative 'registry/skill_source_resolver'
|
|
20
20
|
require_relative 'registry/pack_resolver'
|
|
21
|
+
require_relative 'registry/lockfile'
|
|
21
22
|
require_relative 'registry/resolver_cache'
|
|
22
23
|
require_relative 'registry/rake_presenter'
|
|
23
24
|
|
|
@@ -81,6 +82,23 @@ module RailsAiBridge
|
|
|
81
82
|
resolver_cache.fetch(config) { build_resolver_uncached(config) }
|
|
82
83
|
end
|
|
83
84
|
|
|
85
|
+
# Writes a lockfile containing the current HEAD commit SHA for every pack in
|
|
86
|
+
# the registry manifest.
|
|
87
|
+
#
|
|
88
|
+
# @param config [RailsAiBridge::Config::Registry] registry configuration
|
|
89
|
+
# @return [void]
|
|
90
|
+
# @raise [ArgumentError] when the manifest file does not exist
|
|
91
|
+
def self.write_lockfile(config = RailsAiBridge.configuration.registry)
|
|
92
|
+
manifest_path = config.registry_manifest_path
|
|
93
|
+
raise ArgumentError, "Registry manifest not found at #{manifest_path}" unless File.exist?(manifest_path)
|
|
94
|
+
|
|
95
|
+
manifest = RegistryManifest.from_file(manifest_path)
|
|
96
|
+
git_runner = DefaultGitRunner.new(timeout: config.git_timeout)
|
|
97
|
+
source_resolver = SkillSourceResolver.new(config.skill_cache_dir, git_runner, pull_ttl: config.git_pull_ttl)
|
|
98
|
+
|
|
99
|
+
Lockfile.write(config.lockfile_path, manifest, source_resolver)
|
|
100
|
+
end
|
|
101
|
+
|
|
84
102
|
# @api private
|
|
85
103
|
def self.build_resolver_uncached(config)
|
|
86
104
|
manifest_path = config.registry_manifest_path
|
|
@@ -22,6 +22,11 @@ module RailsAiBridge
|
|
|
22
22
|
# Error message template for unknown transport types
|
|
23
23
|
UNKNOWN_TRANSPORT_ERROR = 'Unknown transport: %s. Use :stdio, :http, or :streamable_http'
|
|
24
24
|
|
|
25
|
+
# Warning printed when HTTP MCP starts without authentication in non-production environments.
|
|
26
|
+
HTTP_AUTH_WARNING = '[rails-ai-bridge] WARNING: HTTP MCP is running without authentication. ' \
|
|
27
|
+
'Set config.mcp.require_http_auth = true or configure http_mcp_token, ' \
|
|
28
|
+
'mcp_token_resolver, or mcp_jwt_decoder before exposing this endpoint.'
|
|
29
|
+
|
|
25
30
|
# Built-in MCP tools that are always available
|
|
26
31
|
# These tools provide Rails application introspection capabilities
|
|
27
32
|
TOOLS = [
|
|
@@ -50,9 +55,14 @@ module RailsAiBridge
|
|
|
50
55
|
end
|
|
51
56
|
|
|
52
57
|
# Returns all available tool classes including additional configured tools.
|
|
53
|
-
#
|
|
58
|
+
# When tool result caching is enabled, built-in and additional tools are wrapped
|
|
59
|
+
# so identical argument fingerprints reuse cached +MCP::Tool::Response+ objects.
|
|
60
|
+
#
|
|
61
|
+
# @return [Array<Class, ToolResultCache::CachedTool>] list of tool classes
|
|
54
62
|
def tool_classes
|
|
55
|
-
TOOLS + RailsAiBridge.configuration.additional_tools
|
|
63
|
+
(TOOLS + RailsAiBridge.configuration.additional_tools).map do |tool_class|
|
|
64
|
+
Instrumentation::InstrumentedTool.new(ToolResultCache.maybe_wrap(tool_class))
|
|
65
|
+
end
|
|
56
66
|
end
|
|
57
67
|
|
|
58
68
|
# Build and return the configured MCP::Server instance.
|
|
@@ -114,6 +124,7 @@ module RailsAiBridge
|
|
|
114
124
|
transport = create_http_transport(server)
|
|
115
125
|
rack_app = build_rack_app(transport, config.http_path)
|
|
116
126
|
|
|
127
|
+
warn_if_http_mcp_unauthenticated
|
|
117
128
|
log_http_startup(config)
|
|
118
129
|
run_rack_server(rack_app, config)
|
|
119
130
|
end
|
|
@@ -153,6 +164,18 @@ module RailsAiBridge
|
|
|
153
164
|
warn TOOLS_LIST_MESSAGE % tool_classes.map(&:tool_name).join(', ')
|
|
154
165
|
end
|
|
155
166
|
|
|
167
|
+
# Emits a one-time stderr warning when HTTP MCP starts without authentication
|
|
168
|
+
# in non-production environments. Production deployments either raise during
|
|
169
|
+
# validation or are assumed to be deliberately configured.
|
|
170
|
+
#
|
|
171
|
+
# @return [void]
|
|
172
|
+
def warn_if_http_mcp_unauthenticated
|
|
173
|
+
return if defined?(Rails) && Rails.env.production?
|
|
174
|
+
return if Mcp::Authenticator.any_configured?
|
|
175
|
+
|
|
176
|
+
warn HTTP_AUTH_WARNING
|
|
177
|
+
end
|
|
178
|
+
|
|
156
179
|
# Runs the Rack server with fallback for older Rack versions.
|
|
157
180
|
# @param rack_app [Object] the Rack application
|
|
158
181
|
# @param config [RailsAiBridge::Configuration] bridge configuration
|
|
@@ -279,6 +279,19 @@ namespace :ai do
|
|
|
279
279
|
end
|
|
280
280
|
end
|
|
281
281
|
|
|
282
|
+
namespace :ai do
|
|
283
|
+
namespace :registry do
|
|
284
|
+
desc 'Generate or update the skill pack lockfile (config/rails_ai_bridge/directory.lock)'
|
|
285
|
+
task lockfile: :environment do
|
|
286
|
+
require 'rails_ai_bridge'
|
|
287
|
+
|
|
288
|
+
config = RailsAiBridge.configuration.registry
|
|
289
|
+
RailsAiBridge::Registry.write_lockfile(config)
|
|
290
|
+
puts "📝 Wrote skill pack lockfile to #{config.lockfile_path}"
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
282
295
|
namespace :ai do
|
|
283
296
|
namespace :skills do
|
|
284
297
|
desc 'List all available skills from configured skill packs'
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'delegate'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'mcp'
|
|
7
|
+
|
|
8
|
+
module RailsAiBridge
|
|
9
|
+
# Caches MCP tool call results keyed by a stable fingerprint of the arguments.
|
|
10
|
+
#
|
|
11
|
+
# The cache is in-memory, per-process, and TTL-based. It ignores +server_context+
|
|
12
|
+
# when fingerprinting so identical client requests share the same cached response.
|
|
13
|
+
#
|
|
14
|
+
# Enable by setting +config.mcp.tool_result_cache_ttl+ to a positive number of seconds.
|
|
15
|
+
class ToolResultCache
|
|
16
|
+
@cache = {}
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
|
|
19
|
+
# Wraps a tool class so that calls are routed through the cache.
|
|
20
|
+
class CachedTool < SimpleDelegator
|
|
21
|
+
def initialize(tool_class)
|
|
22
|
+
super
|
|
23
|
+
@tool_class = tool_class
|
|
24
|
+
@server_context_param = detect_server_context_param(tool_class.method(:call))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param server_context [Object, nil] passed by the MCP transport (ignored for cache keys)
|
|
28
|
+
# @param arguments [Hash] tool arguments from the client
|
|
29
|
+
# @return [MCP::Tool::Response]
|
|
30
|
+
def call(server_context: nil, **arguments)
|
|
31
|
+
ToolResultCache.fetch_response(@tool_class.tool_name, arguments) do
|
|
32
|
+
invoke_tool(arguments, server_context)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def invoke_tool(arguments, server_context)
|
|
39
|
+
case @server_context_param
|
|
40
|
+
when :server_context
|
|
41
|
+
@tool_class.call(**arguments, server_context: server_context)
|
|
42
|
+
when :_server_context
|
|
43
|
+
@tool_class.call(**arguments, _server_context: server_context)
|
|
44
|
+
else
|
|
45
|
+
@tool_class.call(**arguments)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def detect_server_context_param(method_object)
|
|
50
|
+
parameters = method_object.parameters
|
|
51
|
+
keyish = %i[key keyreq].freeze
|
|
52
|
+
return :server_context if parameters.any? { |type, name| name == :server_context && keyish.include?(type) }
|
|
53
|
+
return :_server_context if parameters.any? { |type, name| name == :_server_context && keyish.include?(type) }
|
|
54
|
+
return :server_context if parameters.any? { |type, _| type == :keyrest }
|
|
55
|
+
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class << self
|
|
61
|
+
# Wraps a tool class if caching is enabled; otherwise returns the class unchanged.
|
|
62
|
+
#
|
|
63
|
+
# @param tool_class [Class] an +MCP::Tool+ subclass
|
|
64
|
+
# @return [Class, CachedTool]
|
|
65
|
+
def maybe_wrap(tool_class)
|
|
66
|
+
enabled? ? wrap(tool_class) : tool_class
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Wraps a tool class so every call goes through the cache.
|
|
70
|
+
#
|
|
71
|
+
# @param tool_class [Class]
|
|
72
|
+
# @return [CachedTool]
|
|
73
|
+
def wrap(tool_class)
|
|
74
|
+
CachedTool.new(tool_class)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Fetches a cached response or yields to compute it.
|
|
78
|
+
#
|
|
79
|
+
# @param tool_name [String]
|
|
80
|
+
# @param arguments [Hash]
|
|
81
|
+
# @yieldreturn [MCP::Tool::Response]
|
|
82
|
+
# @return [MCP::Tool::Response]
|
|
83
|
+
def fetch_response(tool_name, arguments)
|
|
84
|
+
return yield unless enabled?
|
|
85
|
+
|
|
86
|
+
key = cache_key(tool_name, arguments)
|
|
87
|
+
fingerprint = key.split(':', 2).last
|
|
88
|
+
|
|
89
|
+
mutex.synchronize do
|
|
90
|
+
entry = cache[key]
|
|
91
|
+
if entry && ttl_valid?(entry)
|
|
92
|
+
Instrumentation.instrument('tool.result_cache_hit', tool_name: tool_name, fingerprint: fingerprint)
|
|
93
|
+
return entry[:response]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
Instrumentation.instrument('tool.result_cache_miss', tool_name: tool_name, fingerprint: fingerprint) do
|
|
98
|
+
response = yield
|
|
99
|
+
mutex.synchronize { cache[key] = { response: response, fetched_at: monotonic_now } }
|
|
100
|
+
response
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Clears all cached tool results.
|
|
105
|
+
#
|
|
106
|
+
# @return [void]
|
|
107
|
+
def reset!
|
|
108
|
+
mutex.synchronize { @cache = {} }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @return [Boolean] whether tool result caching is enabled
|
|
112
|
+
def enabled?
|
|
113
|
+
RailsAiBridge.configuration.mcp.tool_result_cache_ttl.to_i.positive?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
attr_reader :cache, :mutex
|
|
119
|
+
|
|
120
|
+
def cache_key(tool_name, arguments)
|
|
121
|
+
"#{tool_name}:#{argument_fingerprint(arguments)}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def argument_fingerprint(arguments)
|
|
125
|
+
normalized = arguments.transform_keys(&:to_s).sort.to_h
|
|
126
|
+
Digest::SHA256.hexdigest(JSON.generate(normalized))
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def ttl_valid?(entry)
|
|
130
|
+
(monotonic_now - entry[:fetched_at]) < RailsAiBridge.configuration.mcp.tool_result_cache_ttl.to_i
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def monotonic_now
|
|
134
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -19,8 +19,13 @@ module RailsAiBridge
|
|
|
19
19
|
detail: {
|
|
20
20
|
type: 'string',
|
|
21
21
|
enum: %w[summary standard full],
|
|
22
|
-
description: 'Detail level for model listing. summary: names only. standard: names + association/validation counts
|
|
22
|
+
description: 'Detail level for model listing. summary: names only (default). standard: names + association/validation counts. full: names + full association list.
|
|
23
23
|
Ignored when specific model is given (always returns full).'
|
|
24
|
+
},
|
|
25
|
+
format: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
enum: %w[json markdown],
|
|
28
|
+
description: 'Output format. Default: markdown.'
|
|
24
29
|
}
|
|
25
30
|
}
|
|
26
31
|
)
|
|
@@ -37,13 +42,13 @@ module RailsAiBridge
|
|
|
37
42
|
# @param detail [String] +summary+, +standard+, or +full+ when listing (ignored when +model+ is set)
|
|
38
43
|
# @param _server_context [Object, nil] reserved for MCP transport metadata (unused)
|
|
39
44
|
# @return [MCP::Tool::Response] Markdown body or an error string wrapped for the MCP client
|
|
40
|
-
def self.call(model: nil, detail: '
|
|
45
|
+
def self.call(model: nil, detail: 'summary', format: 'markdown', _server_context: nil)
|
|
41
46
|
models = cached_section(:models)
|
|
42
47
|
return text_response('Model introspection not available. Add :models to introspectors.') unless models
|
|
43
48
|
return text_response("Model introspection failed: #{models[:error]}") if models[:error]
|
|
44
49
|
|
|
45
50
|
non_ar = cached_section(:non_ar_models)
|
|
46
|
-
formatter = ResponseFormatter.new(models, model: model, detail: detail, non_ar_models: non_ar)
|
|
51
|
+
formatter = ResponseFormatter.new(models, model: model, detail: detail, format: format, non_ar_models: non_ar)
|
|
47
52
|
return text_response(formatter.model_not_found_message) if formatter.model_not_found?
|
|
48
53
|
return text_response(formatter.model_error_message) if formatter.model_error?
|
|
49
54
|
|
|
@@ -57,10 +62,11 @@ module RailsAiBridge
|
|
|
57
62
|
# @param model [String, nil] requested single-model name
|
|
58
63
|
# @param detail [String] list detail level when +model+ is +nil+
|
|
59
64
|
# @param non_ar_models [Hash, nil] +:non_ar_models+ introspection section (may be +nil+ if disabled)
|
|
60
|
-
def initialize(models, model:, detail:, non_ar_models: nil)
|
|
65
|
+
def initialize(models, model:, detail:, format:, non_ar_models: nil)
|
|
61
66
|
@models = models
|
|
62
67
|
@model = model
|
|
63
68
|
@detail = detail
|
|
69
|
+
@format = format
|
|
64
70
|
@non_ar_models = non_ar_models
|
|
65
71
|
end
|
|
66
72
|
|
|
@@ -89,8 +95,10 @@ module RailsAiBridge
|
|
|
89
95
|
"Error inspecting #{model_key}: #{model_data[:error]}"
|
|
90
96
|
end
|
|
91
97
|
|
|
92
|
-
# @return [String] Markdown for one model, POJO stub, or full listing
|
|
98
|
+
# @return [String] Markdown/JSON for one model, POJO stub, or full listing
|
|
93
99
|
def format
|
|
100
|
+
return json_payload if @format == 'json'
|
|
101
|
+
|
|
94
102
|
if @model
|
|
95
103
|
return pojo_detail_markdown if pojo_entry && !model_data
|
|
96
104
|
|
|
@@ -107,6 +115,16 @@ module RailsAiBridge
|
|
|
107
115
|
|
|
108
116
|
private
|
|
109
117
|
|
|
118
|
+
def json_payload
|
|
119
|
+
if @model
|
|
120
|
+
return pojo_entry.to_h.to_json if pojo_entry && !model_data
|
|
121
|
+
|
|
122
|
+
model_data.to_json
|
|
123
|
+
else
|
|
124
|
+
@models.to_json
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
110
128
|
def pojo_entry
|
|
111
129
|
return @pojo_entry if defined?(@pojo_entry)
|
|
112
130
|
|
|
@@ -26,6 +26,11 @@ module RailsAiBridge
|
|
|
26
26
|
offset: {
|
|
27
27
|
type: 'integer',
|
|
28
28
|
description: 'Skip routes for pagination. Default: 0.'
|
|
29
|
+
},
|
|
30
|
+
format: {
|
|
31
|
+
type: 'string',
|
|
32
|
+
enum: %w[json markdown],
|
|
33
|
+
description: 'Output format. Default: markdown.'
|
|
29
34
|
}
|
|
30
35
|
}
|
|
31
36
|
)
|
|
@@ -38,12 +43,13 @@ module RailsAiBridge
|
|
|
38
43
|
# @param offset [Integer] pagination offset
|
|
39
44
|
# @param _server_context [Object, nil] reserved for MCP transport metadata (unused)
|
|
40
45
|
# @return [MCP::Tool::Response] markdown routes output or an error message
|
|
41
|
-
def self.call(controller: nil, detail: 'standard', limit: nil, offset: 0, _server_context: nil)
|
|
46
|
+
def self.call(controller: nil, detail: 'standard', limit: nil, offset: 0, format: 'markdown', _server_context: nil)
|
|
42
47
|
routes = cached_section(:routes)
|
|
43
48
|
return text_response('Route introspection not available. Add :routes to introspectors.') unless routes
|
|
44
49
|
return text_response("Route introspection failed: #{routes[:error]}") if routes[:error]
|
|
45
50
|
|
|
46
|
-
formatter = ResponseFormatter.new(routes, controller: controller, detail: detail, limit: limit, offset: offset
|
|
51
|
+
formatter = ResponseFormatter.new(routes, controller: controller, detail: detail, limit: limit, offset: offset,
|
|
52
|
+
format: format)
|
|
47
53
|
return text_response(formatter.filter_error_message) if formatter.filter_error?
|
|
48
54
|
|
|
49
55
|
text_response(formatter.format)
|
|
@@ -52,12 +58,13 @@ module RailsAiBridge
|
|
|
52
58
|
# @private
|
|
53
59
|
# Formats +:routes+ introspection for {GetRoutes}.
|
|
54
60
|
class ResponseFormatter
|
|
55
|
-
def initialize(routes, controller:, detail:, limit:, offset:)
|
|
61
|
+
def initialize(routes, controller:, detail:, limit:, offset:, format:)
|
|
56
62
|
@routes = routes
|
|
57
63
|
@controller = controller
|
|
58
64
|
@detail = detail
|
|
59
65
|
@limit = limit
|
|
60
66
|
@offset = [offset.to_i, 0].max
|
|
67
|
+
@format = format
|
|
61
68
|
@by_controller = filter_by_controller
|
|
62
69
|
end
|
|
63
70
|
|
|
@@ -70,6 +77,8 @@ module RailsAiBridge
|
|
|
70
77
|
end
|
|
71
78
|
|
|
72
79
|
def format
|
|
80
|
+
return json_payload if @format == 'json'
|
|
81
|
+
|
|
73
82
|
case @detail
|
|
74
83
|
when 'summary' then format_summary
|
|
75
84
|
when 'standard' then format_standard
|
|
@@ -80,6 +89,14 @@ module RailsAiBridge
|
|
|
80
89
|
|
|
81
90
|
private
|
|
82
91
|
|
|
92
|
+
def json_payload
|
|
93
|
+
{
|
|
94
|
+
total: route_count,
|
|
95
|
+
offset: @offset,
|
|
96
|
+
controllers: @by_controller.sort.to_h
|
|
97
|
+
}.to_json
|
|
98
|
+
end
|
|
99
|
+
|
|
83
100
|
def filter_by_controller
|
|
84
101
|
return @routes[:by_controller] || {} unless @controller
|
|
85
102
|
|
|
@@ -17,7 +17,7 @@ module RailsAiBridge
|
|
|
17
17
|
detail: {
|
|
18
18
|
type: 'string',
|
|
19
19
|
enum: %w[summary standard full],
|
|
20
|
-
description: 'Detail level. summary: table names + column counts. standard: table names + column names/types
|
|
20
|
+
description: 'Detail level. summary: table names + column counts (default). standard: table names + column names/types. ' \
|
|
21
21
|
'full: everything including indexes, FKs, comments.'
|
|
22
22
|
},
|
|
23
23
|
limit: {
|
|
@@ -50,7 +50,7 @@ module RailsAiBridge
|
|
|
50
50
|
# @param format [String] +markdown+ or +json+
|
|
51
51
|
# @param _server_context [Object, nil] reserved for MCP transport metadata (unused)
|
|
52
52
|
# @return [MCP::Tool::Response] schema markdown/JSON or an error message
|
|
53
|
-
def self.call(table: nil, detail: '
|
|
53
|
+
def self.call(table: nil, detail: 'summary', limit: nil, offset: 0, format: 'markdown', _server_context: nil)
|
|
54
54
|
schema = cached_section(:schema)
|
|
55
55
|
return text_response('Schema introspection not available. Add :schema to introspectors.') unless schema
|
|
56
56
|
return text_response("Schema introspection not available: #{schema[:error]}") if schema[:error]
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-ai-bridge
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.5.
|
|
4
|
+
version: 3.5.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ismael Marin
|
|
@@ -183,6 +183,7 @@ files:
|
|
|
183
183
|
- lib/rails_ai_bridge/fingerprinter/cached_snapshot.rb
|
|
184
184
|
- lib/rails_ai_bridge/freshness_header.rb
|
|
185
185
|
- lib/rails_ai_bridge/http_transport_app.rb
|
|
186
|
+
- lib/rails_ai_bridge/instrumentation.rb
|
|
186
187
|
- lib/rails_ai_bridge/introspector.rb
|
|
187
188
|
- lib/rails_ai_bridge/introspector/parallel_runner.rb
|
|
188
189
|
- lib/rails_ai_bridge/introspector/timed_runner.rb
|
|
@@ -229,6 +230,7 @@ files:
|
|
|
229
230
|
- lib/rails_ai_bridge/mcp/auth/strategies/jwt.rb
|
|
230
231
|
- lib/rails_ai_bridge/mcp/auth_result.rb
|
|
231
232
|
- lib/rails_ai_bridge/mcp/authenticator.rb
|
|
233
|
+
- lib/rails_ai_bridge/mcp/cache_rate_limiter.rb
|
|
232
234
|
- lib/rails_ai_bridge/mcp/http_rate_limiter.rb
|
|
233
235
|
- lib/rails_ai_bridge/mcp/http_structured_log.rb
|
|
234
236
|
- lib/rails_ai_bridge/middleware.rb
|
|
@@ -236,6 +238,7 @@ files:
|
|
|
236
238
|
- lib/rails_ai_bridge/path_resolver.rb
|
|
237
239
|
- lib/rails_ai_bridge/registry.rb
|
|
238
240
|
- lib/rails_ai_bridge/registry/frontmatter_parser.rb
|
|
241
|
+
- lib/rails_ai_bridge/registry/lockfile.rb
|
|
239
242
|
- lib/rails_ai_bridge/registry/pack_definition.rb
|
|
240
243
|
- lib/rails_ai_bridge/registry/pack_detector.rb
|
|
241
244
|
- lib/rails_ai_bridge/registry/pack_resolver.rb
|
|
@@ -339,6 +342,7 @@ files:
|
|
|
339
342
|
- lib/rails_ai_bridge/services/context_generation_service.rb
|
|
340
343
|
- lib/rails_ai_bridge/services/file_management_service.rb
|
|
341
344
|
- lib/rails_ai_bridge/tasks/rails_ai_bridge.rake
|
|
345
|
+
- lib/rails_ai_bridge/tool_result_cache.rb
|
|
342
346
|
- lib/rails_ai_bridge/tools/README.md
|
|
343
347
|
- lib/rails_ai_bridge/tools/base_tool.rb
|
|
344
348
|
- lib/rails_ai_bridge/tools/get_config.rb
|