mcp_on_ruby 2.0.0 → 2.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 611ceb54904a193a5ac06d7fc7d223d835067a3d2232b05fc8a46dd1b14b96ac
4
- data.tar.gz: 8d368c37165bf816104cb32345f7576b95e6e36ff5a01e3b2ecf7a07e497172a
3
+ metadata.gz: a99b9f1fff3f7a9639c85c82826690264ca0a2032321c227a3424719d73fbdd5
4
+ data.tar.gz: a977652f5dd0e15d31142a1561f9ab9302a1f34abcfc21a759d5b4ce3600b460
5
5
  SHA512:
6
- metadata.gz: 1c0528601bea68540a356a143abc104267e3b3bd8972c38544f13d0a1dd49696ab2d62d571ca6f51a9bdc0b5088e8f0eac4bebd18cef307759babfeaadc1e4d0
7
- data.tar.gz: 183338a617d68e0e7fd963d627439e62cfdf66045f80f621b778cd12294ab34bf015f2ccf813ca3b747ce74c7bf1ef01e193e785423f6e94e4fac2e8b7918804
6
+ metadata.gz: 1b8dda36e0e1a44f7b67cbb5f1bcbfe91065ff6304c60682a289f524187532a625e1e691c99ef5bd71424ee19f35a2c9151561ffdfe53b34c1aa56919956e25e
7
+ data.tar.gz: e484ae0cee070df784e3621e63d78b982b5bd70bcd02d566f3a74127781c026a0bf683a271814936118ce67416879fd0d4bbf596646d8952d5e61c5ec3e5eea8
data/CHANGELOG.md CHANGED
@@ -2,12 +2,26 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Entries from 2.0.0 onward
6
+ are generated by [release-please](https://github.com/googleapis/release-please) from Conventional Commit PR titles.
7
7
 
8
- ## [Unreleased]
8
+ ## [2.0.2](https://github.com/nagstler/mcp_on_ruby/compare/v2.0.1...v2.0.2) (2026-10-31)
9
9
 
10
- ## [2.0.0] - 2026-10-31
10
+
11
+ ### Bug Fixes
12
+
13
+ * leave belongs_to foreign keys out of scaffolded params ([#37](https://github.com/nagstler/mcp_on_ruby/issues/37)) ([7b7ec5b](https://github.com/nagstler/mcp_on_ruby/commit/7b7ec5bbe40f0dfeec88da94c9deef6de90167bc))
14
+ * report redirects to a sign-in page as not authenticated ([#38](https://github.com/nagstler/mcp_on_ruby/issues/38)) ([949a340](https://github.com/nagstler/mcp_on_ruby/commit/949a34059e925bdad17586cf5bfdab0a68aca031))
15
+
16
+ ## [2.0.1](https://github.com/nagstler/mcp_on_ruby/compare/v2.0.0...v2.0.1) (2026-10-31)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * reuse tool classes when writes are disabled ([#33](https://github.com/nagstler/mcp_on_ruby/issues/33)) ([44be48c](https://github.com/nagstler/mcp_on_ruby/commit/44be48cafcb508a559b718f676e27990277bd41b))
22
+ * validate declarations once routes are loaded ([#32](https://github.com/nagstler/mcp_on_ruby/issues/32)) ([bf96834](https://github.com/nagstler/mcp_on_ruby/commit/bf968346f9131c6f898bdcf152726973079f3429))
23
+
24
+ ## [2.0.0](https://github.com/nagstler/mcp_on_ruby/compare/v1.0.0...v2.0.0) (2026-10-31)
11
25
 
12
26
  MCP on Ruby is now a Rails engine that exposes existing controller actions as MCP tools,
13
27
  built on the official [`mcp`](https://github.com/modelcontextprotocol/ruby-sdk) gem.
@@ -99,7 +113,3 @@ built on the official [`mcp`](https://github.com/modelcontextprotocol/ruby-sdk)
99
113
  - Resource management
100
114
  - Prompt handling
101
115
  - Root filesystem access
102
-
103
- [Unreleased]: https://github.com/nagstler/mcp_on_ruby/compare/v2.0.0...HEAD
104
- [2.0.0]: https://github.com/nagstler/mcp_on_ruby/compare/v1.0.0...v2.0.0
105
- [1.0.0]: https://github.com/nagstler/mcp_on_ruby/releases/tag/v1.0.0
data/LICENSE.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2024
3
+ Copyright (c) 2024-present Nagendra Dhanakeerthi
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -107,21 +107,12 @@ Then ask: *"Which of my products cost more than $50?"*
107
107
 
108
108
  ## How it works
109
109
 
110
- ```
111
- MCP client (Claude, Cursor, …)
112
- │ Streamable HTTP (/mcp) or stdio (bin/rails mcp:stdio)
113
- ▼
114
- official mcp gem ──────── protocol, version negotiation, schema validation
115
- │ tools/call orders_index { "status": "pending" }
116
- ▼
117
- MCP on Ruby
118
- Registry which controller actions are exposed, with their routes and schemas
119
- Dispatcher builds an in-process request: GET /orders?status=pending
120
- Identity the caller's Authorization header and cookies, or the stdio user
121
- Result 2xx JSON → structured result; 4xx/5xx → readable tool error
122
- ▼
123
- Your Rails app: middleware → routes → before_action → controller → render json
124
- ```
110
+ <p align="center">
111
+ <picture>
112
+ <source media="(prefers-color-scheme: dark)" srcset="docs/assets/how-it-works-dark.svg">
113
+ <img src="docs/assets/how-it-works.svg" width="960" alt="An MCP client sends a tool call over HTTP or stdio to the official mcp gem, which hands it to MCP on Ruby. MCP on Ruby runs it as an ordinary Rails request through your app, as the caller, and the JSON response comes back as the tool result.">
114
+ </picture>
115
+ </p>
125
116
 
126
117
  Each tool call becomes an ordinary request through your full middleware stack. Anything that protects your API, including `rate_limit`, Rack::Attack, tenant scoping, and audit logging, protects agent traffic too.
127
118
 
@@ -181,7 +172,30 @@ McpOnRuby.configure do |config|
181
172
  end
182
173
  ```
183
174
 
184
- For Devise and Warden, the user is signed in with the `:user` scope (change it with `config.warden_scope`). Apps with custom authentication can read the stdio user with `mcp_user` in their own `before_action`.
175
+ For Devise and Warden, the user is signed in with the `:user` scope (change it with `config.warden_scope`). Other apps read the stdio user with `mcp_user`.
176
+
177
+ **Rails 8 authentication** (`bin/rails generate authentication`) uses neither Warden nor tokens. Teach its concern about API tokens for HTTP clients and about `mcp_user` for stdio:
178
+
179
+ ```ruby
180
+ # app/controllers/concerns/authentication.rb
181
+ def resume_session
182
+ Current.session ||= find_session_by_cookie || find_session_by_token || find_session_for_mcp
183
+ end
184
+
185
+ # HTTP clients send `Authorization: Bearer <api_token>` (add `has_secure_token :api_token` to User).
186
+ def find_session_by_token
187
+ authenticate_with_http_token do |token, _options|
188
+ User.find_by(api_token: token)&.sessions&.new(user_agent: request.user_agent, ip_address: request.remote_ip)
189
+ end
190
+ end
191
+
192
+ # `bin/rails mcp:stdio` sessions act as config.stdio_user.
193
+ def find_session_for_mcp
194
+ mcp_user&.sessions&.new
195
+ end
196
+ ```
197
+
198
+ The sessions are built but never saved, so tool calls don't add rows to `sessions` or set cookies.
185
199
 
186
200
  If authentication fails, the agent gets a clear "Not authenticated (401)" tool error and nothing runs.
187
201
 
@@ -218,7 +232,8 @@ See [SECURITY.md](SECURITY.md) for the full threat model.
218
232
  | `401` / `403` / `404` | Tool error: not authenticated / not permitted / not found |
219
233
  | `422` with JSON | Tool error including your validation messages, so the agent can correct itself |
220
234
  | Other `4xx` | Tool error with the status, plus the body when it is JSON |
221
- | `3xx` | Tool error naming the redirect target (redirects are not followed) |
235
+ | `3xx` to a sign-in page | Tool error: not authenticated (Rails 8 authentication and Devise redirect unauthenticated requests) |
236
+ | Other `3xx` | Tool error naming the redirect target (redirects are not followed) |
222
237
  | HTML | Tool error asking for JSON. Use `respond_to` with `format.json`. |
223
238
  | `5xx` or an exception | Generic tool error. Details are reported to `Rails.error`, never sent. |
224
239
 
@@ -274,19 +289,26 @@ claude mcp add my-app -e MCP_USER=alice@example.com -- bin/rails mcp:stdio
274
289
  <details>
275
290
  <summary><b>Claude Desktop</b></summary>
276
291
 
277
- Claude Desktop does not load your shell profile, so start Rails through a login shell to pick up rbenv, asdf, or chruby:
292
+ Claude Desktop starts servers without your shell environment, so rbenv, asdf, and mise aren't loaded. Point it at your app's Ruby directly, and use `-C` to run from the app's directory:
293
+
294
+ ```bash
295
+ # In your app directory, print the Ruby to use:
296
+ ruby -e 'puts RbConfig.ruby'
297
+ ```
278
298
 
279
299
  ```json
280
300
  {
281
301
  "mcpServers": {
282
302
  "my-app": {
283
- "command": "bash",
284
- "args": ["-lc", "cd /path/to/my-app && bin/rails mcp:stdio"],
303
+ "command": "/Users/you/.rbenv/versions/3.4.7/bin/ruby",
304
+ "args": ["-C", "/path/to/my-app", "bin/rails", "mcp:stdio"],
285
305
  "env": { "MCP_USER": "alice@example.com" }
286
306
  }
287
307
  }
288
308
  }
289
309
  ```
310
+
311
+ Update `command` when you upgrade Ruby.
290
312
  </details>
291
313
 
292
314
  <details>
@@ -396,7 +418,7 @@ end
396
418
 
397
419
  | | Supported |
398
420
  |---|---|
399
- | Ruby | 3.2, 3.3, 3.4 |
421
+ | Ruby | 3.2, 3.3, 3.4, 4.0 |
400
422
  | Rails | 7.1, 7.2, 8.0, 8.1 |
401
423
  | MCP protocol | Every version the official `mcp` gem supports, negotiated per client |
402
424
  | Transports | Streamable HTTP (stateless), stdio |
@@ -90,12 +90,19 @@ module McpOnRuby
90
90
  }.fetch(action) { "TODO: describe what #{action} does for the agent" }
91
91
  end
92
92
 
93
+ # Foreign keys of belongs_to associations usually come from the signed-in user or the URL, not the agent.
94
+ def owner_keys(model)
95
+ return [] unless model.respond_to?(:reflect_on_all_associations)
96
+
97
+ model.reflect_on_all_associations(:belongs_to).map { |association| association.foreign_key.to_s }
98
+ end
99
+
93
100
  # A params block built from the model's columns, when the controller has an ActiveRecord model.
94
101
  def model_params
95
102
  model = controller.split("/").last.classify.safe_constantize
96
103
  return unless model.respond_to?(:columns_hash)
97
104
 
98
- columns = model.columns_hash.except(model.primary_key, *SKIPPED_COLUMNS)
105
+ columns = model.columns_hash.except(model.primary_key, *SKIPPED_COLUMNS, *owner_keys(model))
99
106
  return if columns.empty?
100
107
 
101
108
  lines = columns.map do |name, column|
@@ -20,10 +20,15 @@ module McpOnRuby
20
20
  McpOnRuby.registry.reset!
21
21
  end
22
22
 
23
- # Fail at boot, not on the first agent request, when declarations do not match routes.
24
- config.after_initialize do |app|
23
+ config.after_initialize do
25
24
  McpOnRuby::WardenHook.install!
26
- McpOnRuby.registry.tools if app.config.eager_load
25
+ end
26
+
27
+ # Fail at boot, not on the first agent request, when declarations do not match routes.
28
+ # Rails runs after_initialize before it loads routes, so validate once routes are loaded,
29
+ # which happens during boot (after eager loading) when eager_load is on.
30
+ initializer "mcp_on_ruby.validate_declarations" do |app|
31
+ app.config.after_routes_loaded { McpOnRuby.registry.tools } if app.config.eager_load
27
32
  end
28
33
  end
29
34
  end
@@ -6,6 +6,10 @@ module McpOnRuby
6
6
  JSON_TYPE = %r{\Aapplication/(?:[\w.+-]+\+)?json\b}i
7
7
  HTML_TYPE = %r{\Atext/html\b}i
8
8
 
9
+ # Sign-in pages apps redirect unauthenticated requests to: Rails 8 authentication (/session/new),
10
+ # Devise (/users/sign_in), and common /login and /signin routes.
11
+ SIGN_IN_PATH = %r{/(?:session/new|sign_?in|log_?in)/?\z}i
12
+
9
13
  module_function
10
14
 
11
15
  def from_rack(status, headers, body)
@@ -15,8 +19,7 @@ module McpOnRuby
15
19
 
16
20
  case status
17
21
  when 200..299 then success(status, text, json, content_type)
18
- when 300..399 then error(status, "Redirected to #{header(headers, "location") || "another page"}; " \
19
- "redirects are not followed")
22
+ when 300..399 then redirect(status, header(headers, "location"))
20
23
  when 400..499 then client_error(status, text, json)
21
24
  else error(status, "Internal server error (#{status})")
22
25
  end
@@ -36,6 +39,16 @@ module McpOnRuby
36
39
  text_response(text)
37
40
  end
38
41
 
42
+ def redirect(status, location)
43
+ if location && URI(location).path.to_s.match?(SIGN_IN_PATH)
44
+ error(status, "Not authenticated: the app redirected to its sign-in page (#{location})")
45
+ else
46
+ error(status, "Redirected to #{location || "another page"}; redirects are not followed")
47
+ end
48
+ rescue URI::InvalidURIError
49
+ error(status, "Redirected to #{location}; redirects are not followed")
50
+ end
51
+
39
52
  def client_error(status, text, json)
40
53
  message = {
41
54
  401 => "Not authenticated (401)",
@@ -45,12 +45,16 @@ module McpOnRuby
45
45
  @dispatcher ||= Dispatcher.new
46
46
  end
47
47
 
48
- # Tool classes are rebuilt only when the registry is (on boot and code reload).
48
+ # Tool classes are rebuilt only when the registry is (on boot and code reload) or when
49
+ # `allow_writes` changes, never per request.
49
50
  def mcp_tools
50
- specs = served_specs
51
+ key = [registry.tools, configuration.allow_writes]
51
52
  @mutex.synchronize do
52
- @cache = [specs, specs.map { |spec| spec.to_mcp_tool(handler) }] unless @cache&.first.equal?(specs)
53
- @cache.last
53
+ unless @cache && @cache[:registry].equal?(key.first) && @cache[:allow_writes] == key.last
54
+ @cache = { registry: key.first, allow_writes: key.last,
55
+ tools: served_specs.map { |spec| spec.to_mcp_tool(handler) } }
56
+ end
57
+ @cache[:tools]
54
58
  end
55
59
  end
56
60
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module McpOnRuby
4
- VERSION = "2.0.0"
4
+ VERSION = "2.0.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mcp_on_ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nagendra Dhanakeerthi