mcp_on_ruby 0.3.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +90 -26
  3. data/LICENSE.txt +2 -2
  4. data/README.md +308 -462
  5. data/config/routes.rb +5 -0
  6. data/lib/generators/mcp_on_ruby/expose/expose_generator.rb +108 -0
  7. data/lib/generators/mcp_on_ruby/install/install_generator.rb +37 -0
  8. data/lib/generators/mcp_on_ruby/install/templates/initializer.rb +22 -0
  9. data/lib/mcp_on_ruby/call_context.rb +29 -0
  10. data/lib/mcp_on_ruby/configuration.rb +78 -0
  11. data/lib/mcp_on_ruby/controller.rb +60 -0
  12. data/lib/mcp_on_ruby/declaration.rb +24 -0
  13. data/lib/mcp_on_ruby/dispatcher.rb +97 -0
  14. data/lib/mcp_on_ruby/doctor.rb +67 -0
  15. data/lib/mcp_on_ruby/endpoint.rb +43 -0
  16. data/lib/mcp_on_ruby/engine.rb +29 -0
  17. data/lib/mcp_on_ruby/registry.rb +146 -0
  18. data/lib/mcp_on_ruby/result.rb +83 -0
  19. data/lib/mcp_on_ruby/rspec.rb +8 -0
  20. data/lib/mcp_on_ruby/schema.rb +149 -0
  21. data/lib/mcp_on_ruby/server_factory.rb +66 -0
  22. data/lib/mcp_on_ruby/stdio.rb +42 -0
  23. data/lib/mcp_on_ruby/test_helper.rb +86 -0
  24. data/lib/mcp_on_ruby/tool_spec.rb +87 -0
  25. data/lib/mcp_on_ruby/tool_table.rb +31 -0
  26. data/lib/mcp_on_ruby/version.rb +5 -0
  27. data/lib/mcp_on_ruby/warden_hook.rb +21 -0
  28. data/lib/mcp_on_ruby.rb +64 -0
  29. data/lib/tasks/mcp_on_ruby.rake +20 -0
  30. metadata +63 -245
  31. data/CODE_OF_CONDUCT.md +0 -84
  32. data/CONTRIBUTING.md +0 -92
  33. data/lib/ruby_mcp/client.rb +0 -43
  34. data/lib/ruby_mcp/configuration.rb +0 -90
  35. data/lib/ruby_mcp/errors.rb +0 -17
  36. data/lib/ruby_mcp/models/context.rb +0 -52
  37. data/lib/ruby_mcp/models/engine.rb +0 -31
  38. data/lib/ruby_mcp/models/message.rb +0 -60
  39. data/lib/ruby_mcp/providers/anthropic.rb +0 -269
  40. data/lib/ruby_mcp/providers/base.rb +0 -57
  41. data/lib/ruby_mcp/providers/openai.rb +0 -265
  42. data/lib/ruby_mcp/schemas.rb +0 -56
  43. data/lib/ruby_mcp/server/app.rb +0 -84
  44. data/lib/ruby_mcp/server/base_controller.rb +0 -49
  45. data/lib/ruby_mcp/server/content_controller.rb +0 -68
  46. data/lib/ruby_mcp/server/contexts_controller.rb +0 -67
  47. data/lib/ruby_mcp/server/controller.rb +0 -29
  48. data/lib/ruby_mcp/server/engines_controller.rb +0 -34
  49. data/lib/ruby_mcp/server/generate_controller.rb +0 -140
  50. data/lib/ruby_mcp/server/messages_controller.rb +0 -30
  51. data/lib/ruby_mcp/server/router.rb +0 -84
  52. data/lib/ruby_mcp/storage/active_record.rb +0 -414
  53. data/lib/ruby_mcp/storage/base.rb +0 -43
  54. data/lib/ruby_mcp/storage/error.rb +0 -8
  55. data/lib/ruby_mcp/storage/memory.rb +0 -69
  56. data/lib/ruby_mcp/storage/redis.rb +0 -197
  57. data/lib/ruby_mcp/storage_factory.rb +0 -43
  58. data/lib/ruby_mcp/validator.rb +0 -45
  59. data/lib/ruby_mcp/version.rb +0 -6
  60. data/lib/ruby_mcp.rb +0 -71
data/README.md CHANGED
@@ -1,590 +1,436 @@
1
1
  <div align="center">
2
2
 
3
3
  # MCP on Ruby
4
- [![Gem Version](https://badge.fury.io/rb/mcp_on_ruby.svg)](https://badge.fury.io/rb/mcp_on_ruby)
5
- [![Build](https://github.com/nagstler/ruby_mcp/actions/workflows/build.yml/badge.svg)](https://github.com/nagstler/ruby_mcp/actions/workflows/build.yml)
6
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
- [![RuboCop](https://github.com/nagstler/ruby_mcp/actions/workflows/rubocop.yml/badge.svg)](https://github.com/nagstler/ruby_mcp/actions/workflows/rubocop.yml)
8
- [![Test](https://github.com/nagstler/ruby_mcp/actions/workflows/test.yml/badge.svg)](https://github.com/nagstler/ruby_mcp/actions/workflows/test.yml)
9
- [![codecov](https://codecov.io/github/nagstler/ruby_mcp/graph/badge.svg?token=SG4EJEIHW3)](https://codecov.io/github/nagstler/ruby_mcp)
10
4
 
11
- <strong> **Turn your Rails APIs into an MCP server.**</strong>
5
+ **Make your Rails app usable by AI agents.**
6
+
7
+ Your existing controllers become [Model Context Protocol](https://modelcontextprotocol.io) tools, running through<br>
8
+ the same authentication, policies, params, and JSON you already ship.
9
+
10
+ [![Gem Version](https://img.shields.io/gem/v/mcp_on_ruby)](https://rubygems.org/gems/mcp_on_ruby)
11
+ [![CI](https://github.com/nagstler/mcp_on_ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/nagstler/mcp_on_ruby/actions/workflows/ci.yml)
12
+ [![Ruby](https://img.shields.io/badge/ruby-3.2%2B-CC342D)](#compatibility)
13
+ [![Rails](https://img.shields.io/badge/rails-7.1%20%E2%80%93%208.1-D30001)](#compatibility)
14
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE.txt)
15
+
16
+ [Quick start](#quick-start) ·
17
+ [How it works](#how-it-works) ·
18
+ [Guides](#guides) ·
19
+ [Configuration](#configuration) ·
20
+ [Testing](#testing)
12
21
 
13
22
  </div>
14
23
 
15
- ## 🔍 Introduction
16
- The [Model Context Protocol](https://modelcontextprotocol.io) standardizes how applications interact with AI models, serving as the "REST for LLMs." **MCP on Ruby** brings this standard to the Ruby ecosystem. Create contexts, manage conversations, connect to multiple providers, and handle streaming responses with clean, Ruby code.
24
+ ---
17
25
 
18
- ![System Component Flow (Horizontal)](https://github.com/user-attachments/assets/085ad9b8-bee0-4d60-a4b7-ecf02d07f53c)
26
+ ```ruby
27
+ class OrdersController < ApplicationController
28
+ before_action :authenticate_user!
19
29
 
20
- > 📌 If you find this useful, **give it a ⭐ on GitHub**
30
+ mcp_tool :index, description: "Search the current user's orders",
31
+ params: { status: { type: :string, enum: %w[pending paid shipped] } }
32
+ mcp_tool :show, description: "Fetch one order by id"
21
33
 
22
- ## 📋 Table of Contents
34
+ def index
35
+ render json: current_user.orders.where(params.permit(:status))
36
+ end
23
37
 
24
- - [🔍 Introduction](#-introduction)
25
- - [🌟 Why MCP on Ruby?](#-why-mcp-on-ruby)
26
- - [📦 Installation](#-installation)
27
- - [🚀 Quick Start](#-quick-start)
28
- - [🎮 Interactive Demo](#-interactive-demo)
29
- - [⚙️ Configuration Options](#️-configuration-options)
30
- - [🛣️ Server Endpoints](#️-server-endpoints)
31
- - [📚 Detailed Usage](#-detailed-usage)
32
- - [Creating a Context](#creating-a-context)
33
- - [Adding a Message](#adding-a-message)
34
- - [Generating a Response](#generating-a-response)
35
- - [Streaming a Response](#streaming-a-response)
36
- - [Uploading Content](#uploading-content)
37
- - [Using Tool Calls](#using-tool-calls)
38
- - [🚄 Rails Integration](#-rails-integration)
39
- - [💾 Storage Backend](#-storage-backends)
40
- - [🔒 Authentication](#-authentication)
41
- - [🛠️ Development](#️-development)
42
- - [🗺️ Roadmap](#️-roadmap)
43
- - [👥 Contributing](#-contributing)
44
- - [📄 License](#-license)
38
+ def show
39
+ render json: current_user.orders.find(params[:id])
40
+ end
41
+ end
42
+ ```
45
43
 
46
- ## 🌟 Why MCP on Ruby?
44
+ That's the whole integration. Claude, Cursor, or any MCP client can now search and read orders **as the signed-in user**. Your `before_action` authenticates the agent, your scopes and policies decide what it can see, and your existing JSON comes back to it. You don't write new tool classes or a second permission system.
47
45
 
48
- **MCP on Ruby** provides a comprehensive implementation of the Model Context Protocol with these features:
46
+ ## Why MCP on Ruby
49
47
 
50
- - **Provider-Ready:** Pre-built adapters for OpenAI and Anthropic - just add your API key
51
- - **Complete Protocol Implementation:** Fully implements the MCP specification for compatibility
52
- - **Conversation Management:** Context handling for multi-turn conversations
53
- - **Flexible Storage:** Extensible storage backends
54
- - **Streaming Support:** Real-time response streaming for dynamic UIs
55
- - **File Handling:** Upload and reference files in conversations
56
- - **Tool Calling:** Support for LLM function calling capabilities
57
- - **Battle-Tested:** Comprehensive test suite ensures reliability
48
+ Most MCP libraries ask you to build a parallel API for agents: new tool classes, new argument parsing, new authorization checks. Over time that second API drifts from the real one, and every rule has to be secured twice.
58
49
 
59
- The library is designed to be straightforward to use while maintaining full compatibility with the MCP specification.
50
+ MCP on Ruby takes the opposite approach. **Your app already has an API: your controllers.** MCP on Ruby makes it callable by agents.
60
51
 
61
- ## 📦 Installation
52
+ - **One source of truth.** Agents go through the same routes, filters, policies, validations, and serializers as your users.
53
+ - **Real identity.** Every tool call runs as a real user, using the auth you already have: API tokens, Devise, or Warden.
54
+ - **Safe by default.** Actions that change data must be marked `write: true`, and every tool tells the client whether it is read-only or destructive.
55
+ - **Local and remote.** Serve Streamable HTTP in production, and stdio for Claude Code, Claude Desktop, and Cursor on your machine.
56
+ - **Built on the official SDK.** The protocol layer is the official [`mcp`](https://github.com/modelcontextprotocol/ruby-sdk) gem, so protocol updates arrive without a rewrite.
57
+ - **Works with the Rails you run today.** Rails 7.1 through 8.1 on Ruby 3.2+.
62
58
 
63
- Add this line to your application's Gemfile:
59
+ ## Quick start
64
60
 
65
- ```ruby
66
- gem 'mcp_on_ruby'
61
+ **1. Install**
62
+
63
+ ```bash
64
+ bundle add mcp_on_ruby
65
+ bin/rails generate mcp_on_ruby:install
67
66
  ```
68
67
 
69
- And then execute:
68
+ The generator creates `config/initializers/mcp_on_ruby.rb` and mounts the endpoint:
70
69
 
71
- ```
72
- $ bundle install
70
+ ```ruby
71
+ # config/routes.rb
72
+ mount McpOnRuby::Engine, at: "/mcp"
73
73
  ```
74
74
 
75
- Or install it yourself as:
75
+ **2. Expose some actions**
76
76
 
77
+ ```bash
78
+ bin/rails generate mcp_on_ruby:expose products index show create
77
79
  ```
78
- $ gem install mcp_on_ruby
80
+
81
+ This adds `mcp_tool` declarations to `ProductsController`, with a params schema for `create` built from the model's columns. Edit the descriptions: they are what the agent reads. Then check what agents will see:
82
+
83
+ ```console
84
+ $ bin/rails mcp:tools
85
+ TOOL ACTION VERB PATH ACCESS
86
+ products_create ProductsController#create POST /products write
87
+ products_index ProductsController#index GET /products read-only
88
+ products_show ProductsController#show GET /products/:id read-only
79
89
  ```
80
90
 
81
- ## 🚀 Quick Start
91
+ **3. Connect an agent**
82
92
 
83
- Here's how to get a basic MCP server running:
93
+ Over HTTP, sending a token your app already accepts:
84
94
 
85
- ```ruby
86
- require 'ruby_mcp'
95
+ ```bash
96
+ claude mcp add --transport http my-app http://localhost:3000/mcp \
97
+ --header "Authorization: Bearer $MY_APP_API_TOKEN"
98
+ ```
87
99
 
88
- # Configure RubyMCP
89
- RubyMCP.configure do |config|
90
- config.providers = {
91
- openai: { api_key: ENV['OPENAI_API_KEY'] },
92
- anthropic: { api_key: ENV['ANTHROPIC_API_KEY'] }
93
- }
94
- end
100
+ Or locally over stdio, acting as a user you choose ([set up `stdio_user`](#authentication) first):
95
101
 
96
- # Start the MCP server
97
- server = RubyMCP::Server::Controller.new
98
- server.start
102
+ ```bash
103
+ claude mcp add my-app -e MCP_USER=alice@example.com -- bin/rails mcp:stdio
99
104
  ```
100
105
 
101
- ### 🎮 Interactive Demo
106
+ Then ask: *"Which of my products cost more than $50?"*
102
107
 
103
- The repository includes an interactive demo that walks through all the key MCP concepts:
108
+ ## How it works
104
109
 
105
- ```bash
106
- # Terminal 1: Start the server
107
- cd examples/simple_server
108
- ruby server.rb
109
-
110
- # Terminal 2: Run the client
111
- cd examples/simple_server
112
- ruby client.rb
113
-
114
- # ActiveRecord Storage Demo
115
- # Demonstrates database storage with SQLite
116
- cd examples/simple_server
117
- ruby activerecord_demo.rb
118
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
+ ```
125
+
126
+ 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.
119
127
 
120
- This demo provides a guided tour of the MCP functionality, showing each step of creating contexts, adding messages, and generating responses with detailed explanations.
128
+ HTTP is served statelessly: every request is self-contained, so any Puma worker or server behind your load balancer can handle any MCP request, with no sticky sessions.
121
129
 
122
- ## ⚙️ Configuration Options
130
+ ## Guides
123
131
 
124
- RubyMCP offers several configuration options:
132
+ ### Declaring tools
125
133
 
126
134
  ```ruby
127
- RubyMCP.configure do |config|
128
- # LLM Provider configurations
129
- config.providers = {
130
- openai: {
131
- api_key: ENV['OPENAI_API_KEY'],
132
- api_base: 'https://api.openai.com/v1' # Optional
133
- },
134
- anthropic: {
135
- api_key: ENV['ANTHROPIC_API_KEY']
136
- }
137
- }
138
-
139
- # Storage backend
140
-
141
- # Option 1: Memory storage (default)
142
- config.storage = :memory
143
-
144
- # Option 2: Redis storage
145
- config.storage = :redis
146
- config.redis = {
147
- url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'),
148
- namespace: 'my_app_mcp',
149
- ttl: 86400 # 1 day in seconds
135
+ mcp_tool :index,
136
+ description: "Search products by name", # required: what the agent reads
137
+ title: "Search products", # optional display name
138
+ name: "search_products", # optional; defaults to "products_index"
139
+ params: {
140
+ q: :string, # shorthand
141
+ sort: { type: :string, enum: %w[name price], default: "name" },
142
+ limit: { type: :integer, minimum: 1, maximum: 100 }
150
143
  }
151
-
152
- # Option 3: ActiveRecord storage
153
- config.storage = :active_record
154
- config.active_record = {
155
- # Connection settings (not needed in Rails)
156
- connection: {
157
- adapter: 'sqlite3',
158
- database: 'db/mcp.sqlite3'
159
- },
160
- # Table prefix to avoid name collisions
161
- table_prefix: 'mcp_'
162
- }
163
-
164
- # Server settings
165
- config.server_port = 3000
166
- config.server_host = "0.0.0.0"
167
-
168
- # Authentication settings
169
- config.auth_required = false
170
- config.jwt_secret = ENV['JWT_SECRET']
171
- config.token_expiry = 3600 # 1 hour
172
-
173
- # Limits
174
- config.max_contexts = 1000
175
- end
176
144
  ```
177
145
 
178
- ## 🛣️ Server Endpoints
146
+ Path parameters such as `:id` in `/products/:id` are read from your routes and added to the schema as required parameters. You don't declare them.
179
147
 
180
- The MCP server provides the following RESTful endpoints:
148
+ Use a block for nested input:
181
149
 
182
- ### Engines
183
- - `GET /engines` - List available language models
150
+ ```ruby
151
+ mcp_tool :create, description: "Place an order", write: true do
152
+ param :order, required: true do
153
+ param :sku, type: :string, required: true
154
+ param :quantity, type: :integer, minimum: 1
155
+ param :tags, type: :array, items: :string
156
+ param :note, type: :string, nullable: true
157
+ end
158
+ end
159
+ ```
184
160
 
185
- ### Contexts
186
- - `POST /contexts` - Create a new conversation context
187
- - `GET /contexts` - List existing contexts
188
- - `GET /contexts/:id` - Get details of a specific context
189
- - `DELETE /contexts/:id` - Delete a context
161
+ Supported types: `:string`, `:integer`, `:number`, `:boolean`, `:array`, `:object`, `:null`, or an array of them. Options use Ruby names for JSON Schema keywords: `description`, `enum`, `default`, `format`, `pattern`, `minimum`, `maximum`, `min_length`, `max_length`, `min_items`, `max_items`, `unique_items`, and more. Unknown types and options raise immediately, so typos don't ship.
190
162
 
191
- ### Messages
192
- - `POST /messages` - Add a message to a context
163
+ The official SDK validates arguments against this schema before your controller runs. Arguments reach `params` exactly as they would from a JSON client, so strong params work unchanged. Read tools receive them as query parameters, write tools as a JSON body.
193
164
 
194
- ### Generation
195
- - `POST /generate` - Generate a response from a language model
196
- - `POST /generate/stream` - Stream a response with incremental updates
165
+ ### Authentication
197
166
 
198
- ### Content
199
- - `POST /content` - Upload content (files)
200
- - `GET /content/:context_id/:id` - Retrieve uploaded content
167
+ MCP on Ruby never asks you to build a new auth system. It hands the caller's identity to the auth you already have.
201
168
 
202
- ## 📚 Detailed Usage
169
+ **Over HTTP**, the client's `Authorization` header and cookies are forwarded to your controller. If your app accepts API tokens, as most API apps and Devise token setups do, there is nothing to configure.
203
170
 
204
- ### Creating a Context
171
+ **Over stdio** there is no HTTP request, so you choose who local sessions act as:
205
172
 
206
173
  ```ruby
207
- # Using the HTTP API
208
- response = Faraday.post(
209
- "http://localhost:3000/contexts",
210
- {
211
- messages: [
212
- {
213
- role: "system",
214
- content: "You are a helpful assistant."
215
- }
216
- ],
217
- metadata: {
218
- user_id: "user_123",
219
- conversation_name: "Technical Support"
220
- }
221
- }.to_json,
222
- "Content-Type" => "application/json"
223
- )
174
+ # config/initializers/mcp_on_ruby.rb
175
+ McpOnRuby.configure do |config|
176
+ # Devise / Warden apps: sign in as a user for each tool call (nothing is stored in the session).
177
+ config.stdio_user = -> { User.find_by!(email: ENV.fetch("MCP_USER")) }
224
178
 
225
- context_id = JSON.parse(response.body)["id"]
179
+ # Token-authenticated apps: send headers your app already accepts.
180
+ config.stdio_headers = -> { { "Authorization" => "Bearer #{ENV.fetch("MCP_TOKEN")}" } }
181
+ end
226
182
  ```
227
183
 
228
- ### Adding a Message
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`.
229
185
 
230
- ```ruby
231
- Faraday.post(
232
- "http://localhost:3000/messages",
233
- {
234
- context_id: context_id,
235
- role: "user",
236
- content: "What is the capital of France?"
237
- }.to_json,
238
- "Content-Type" => "application/json"
239
- )
240
- ```
186
+ If authentication fails, the agent gets a clear "Not authenticated (401)" tool error and nothing runs.
241
187
 
242
- ### Generating a Response
188
+ ### Authorization
243
189
 
244
- ```ruby
245
- response = Faraday.post(
246
- "http://localhost:3000/generate",
247
- {
248
- context_id: context_id,
249
- engine_id: "anthropic/claude-3-sonnet-20240229",
250
- max_tokens: 1000,
251
- temperature: 0.7
252
- }.to_json,
253
- "Content-Type" => "application/json"
254
- )
255
-
256
- assistant_response = JSON.parse(response.body)["content"]
257
- ```
190
+ There is nothing new to learn, because tool calls run your controller code. `authorize`, `policy_scope`, `can?`, `current_user.orders.find`, and `head :forbidden` behave exactly as they do for any other client. A 403 reaches the agent as "Not permitted (403)".
258
191
 
259
- ### Streaming a Response
192
+ Inside a controller, `mcp_request?` tells you whether an agent is calling, and `mcp_tool_name` tells you which tool:
260
193
 
261
194
  ```ruby
262
- conn = Faraday.new do |f|
263
- f.request :json
264
- f.response :json
265
- f.adapter :net_http
266
- end
267
-
268
- conn.post("http://localhost:3000/generate/stream") do |req|
269
- req.headers["Content-Type"] = "application/json"
270
- req.body = {
271
- context_id: context_id,
272
- engine_id: "openai/gpt-4",
273
- temperature: 0.7
274
- }.to_json
275
-
276
- req.options.on_data = Proc.new do |chunk, size, total|
277
- event_data = chunk.split("data: ").last.strip
278
- next if event_data.empty? || event_data == "[DONE]"
279
-
280
- event = JSON.parse(event_data)
281
- if event["event"] == "generation.content" && event["content"]
282
- print event["content"]
283
- end
284
- end
285
- end
195
+ before_action :require_admin, if: :mcp_request?
286
196
  ```
287
197
 
288
- ### Uploading Content
198
+ ### Safety
289
199
 
290
- ```ruby
291
- file_data = Base64.strict_encode64(File.read("example.pdf"))
292
-
293
- Faraday.post(
294
- "http://localhost:3000/content",
295
- {
296
- context_id: context_id,
297
- type: "file",
298
- filename: "example.pdf",
299
- content_type: "application/pdf",
300
- file_data: file_data
301
- }.to_json,
302
- "Content-Type" => "application/json"
303
- )
304
- ```
200
+ Agents act on behalf of real users, so the defaults are conservative:
305
201
 
306
- ### Using Tool Calls
202
+ - **Writes are explicit.** Declaring `mcp_tool` on an action routed with `POST`, `PUT`, `PATCH`, or `DELETE` without `write: true` raises `McpOnRuby::ConfigurationError`. This happens at boot in production and when running `bin/rails mcp:doctor`.
203
+ - **Honest hints.** Every tool is annotated from its HTTP verb (`readOnlyHint`, `destructiveHint`, `idempotentHint`), so clients like Claude can ask before destructive calls. Override with `annotations:`.
204
+ - **A kill switch.** `config.allow_writes = false` hides and refuses every write tool, for example on a read-only replica or a demo environment.
205
+ - **No accidental exposure.** Only declared actions are reachable, and declarations without a route raise. `bin/rails mcp:tools` shows exactly what is exposed.
206
+ - **No leaked internals.** Server errors reach the agent as a generic error. Details go to `Rails.error` and your logs.
207
+ - **Browser protection.** The endpoint rejects requests from foreign browser origins. Requests dispatched by MCP skip Rails' CSRF token check, which is safe because only the dispatcher can mark a request as MCP. Regular requests to the same actions stay protected.
307
208
 
308
- ```ruby
309
- tools = [
310
- {
311
- type: "function",
312
- function: {
313
- name: "get_weather",
314
- description: "Get the current weather for a location",
315
- parameters: {
316
- type: "object",
317
- properties: {
318
- location: {
319
- type: "string",
320
- description: "City and state, e.g., San Francisco, CA"
321
- }
322
- },
323
- required: ["location"]
324
- }
325
- }
326
- }
327
- ]
328
-
329
- response = Faraday.post(
330
- "http://localhost:3000/generate",
331
- {
332
- context_id: context_id,
333
- engine_id: "openai/gpt-4",
334
- tools: tools
335
- }.to_json,
336
- "Content-Type" => "application/json"
337
- )
338
-
339
- if response.body["tool_calls"]
340
- # Handle tool calls
341
- tool_calls = response.body["tool_calls"]
342
- # Process tool calls and add tool response message
343
- end
344
- ```
209
+ See [SECURITY.md](SECURITY.md) for the full threat model.
345
210
 
346
- ## 🚄 Rails Integration
211
+ ### Results and errors
347
212
 
348
- For Rails applications, create an initializer at `config/initializers/ruby_mcp.rb`:
213
+ | Your controller returns | The agent receives |
214
+ |---|---|
215
+ | `2xx` with JSON | `structuredContent` plus the JSON as text |
216
+ | `2xx` with plain text | The text |
217
+ | `204 No Content` | A short success message |
218
+ | `401` / `403` / `404` | Tool error: not authenticated / not permitted / not found |
219
+ | `422` with JSON | Tool error including your validation messages, so the agent can correct itself |
220
+ | 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) |
222
+ | HTML | Tool error asking for JSON. Use `respond_to` with `format.json`. |
223
+ | `5xx` or an exception | Generic tool error. Details are reported to `Rails.error`, never sent. |
224
+
225
+ ### Tools without a controller
226
+
227
+ For logic that has no endpoint, add a tool class from the official SDK. It runs alongside your controller tools:
349
228
 
350
229
  ```ruby
351
- RubyMCP.configure do |config|
352
- config.providers = {
353
- openai: { api_key: ENV['OPENAI_API_KEY'] },
354
- anthropic: { api_key: ENV['ANTHROPIC_API_KEY'] }
355
- }
356
-
357
- # Use memory storage in development, consider persistent storage in production
358
- if Rails.env.development? || Rails.env.test?
359
- config.storage = :memory
360
- else
361
- # Use ActiveRecord for production (uses your Rails database)
362
- config.storage = :active_record
363
- config.active_record = {
364
- table_prefix: "mcp_#{Rails.env}_" # Environment-specific prefix
365
- }
366
- end
367
-
368
- # Enable authentication in production
369
- if Rails.env.production?
370
- config.auth_required = true
371
- config.jwt_secret = ENV["JWT_SECRET"]
230
+ class ReindexSearchTool < MCP::Tool
231
+ description "Rebuild the search index"
232
+
233
+ def self.call(server_context:)
234
+ SearchIndex.rebuild!
235
+ MCP::Tool::Response.new([{ type: "text", text: "Reindexed" }])
372
236
  end
373
237
  end
238
+
239
+ McpOnRuby.configure { |config| config.tools = [ReindexSearchTool] }
374
240
  ```
375
241
 
376
- And mount the server in your `config/routes.rb` file:
242
+ These tools bypass your controllers, so they also bypass your controller authentication. Use them for things that are safe for every caller.
243
+
244
+ ### Observability
245
+
246
+ Every tool call emits `tool_call.mcp_on_ruby` through `ActiveSupport::Notifications`:
377
247
 
378
248
  ```ruby
379
- Rails.application.routes.draw do
380
- # Mount RubyMCP at /api/mcp
381
- mount_mcp_at = "/api/mcp"
382
-
383
- Rails.application.config.middleware.use Rack::Config do |env|
384
- env["SCRIPT_NAME"] = mount_mcp_at if env["PATH_INFO"].start_with?(mount_mcp_at)
385
- end
386
-
387
- mount RubyMCP::Server::App.new.rack_app, at: mount_mcp_at
388
-
389
- # Rest of your routes
390
- # ...
249
+ ActiveSupport::Notifications.subscribe("tool_call.mcp_on_ruby") do |event|
250
+ Rails.logger.info(
251
+ "MCP #{event.payload[:tool]} → #{event.payload[:status]} " \
252
+ "(#{event.payload[:transport]}, #{event.duration.round}ms)"
253
+ )
391
254
  end
392
255
  ```
393
256
 
394
- ## 💾 Storage Backends
257
+ Payload: `tool`, `controller`, `action`, `transport` (`:http` or `:stdio`), `status`, and `error`. Tool calls also show up in your normal request logs and APM traces as requests to the underlying routes.
395
258
 
396
- ### Redis Storage
259
+ ### Connecting clients
397
260
 
398
- MCP on Ruby supports Redis as a persistent storage backend:
261
+ <details>
262
+ <summary><b>Claude Code</b></summary>
399
263
 
400
- 1. Add the Redis gem to your Gemfile:
401
- ```ruby
402
- gem 'redis', '~> 5.0'
403
- ```
404
-
405
- 2. Configure Redis storage:
406
- ```ruby
407
- RubyMCP.configure do |config|
408
- config.storage = :redis
409
- config.redis = {
410
- url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'),
411
- namespace: "app_mcp_#{Rails.env}",
412
- ttl: 86400 # 1 day in seconds
413
- }
414
- end
415
- ```
416
-
417
- 3. Access the configured client:
418
- ```ruby
419
- client = RubyMCP.client
420
- ```
264
+ ```bash
265
+ # Remote or production
266
+ claude mcp add --transport http my-app https://my-app.example.com/mcp \
267
+ --header "Authorization: Bearer $MY_APP_API_TOKEN"
421
268
 
422
- For detailed integration examples, see the [[Redis Storage](https://github.com/nagstler/mcp_on_ruby/wiki/Redis-Storage)] wiki page.
269
+ # Local, over stdio (run from your app's directory)
270
+ claude mcp add my-app -e MCP_USER=alice@example.com -- bin/rails mcp:stdio
271
+ ```
272
+ </details>
423
273
 
424
- ### ActiveRecord Storage
274
+ <details>
275
+ <summary><b>Claude Desktop</b></summary>
425
276
 
426
- For integration with Rails or any app needing database storage:
277
+ Claude Desktop does not load your shell profile, so start Rails through a login shell to pick up rbenv, asdf, or chruby:
427
278
 
428
- ```ruby
429
- # Add to Gemfile
430
- gem 'activerecord', '~> 6.1'
431
- gem 'sqlite3', '~> 1.4' # or pg, mysql2, etc.
432
-
433
- # Configure RubyMCP
434
- RubyMCP.configure do |config|
435
- config.storage = :active_record
436
- config.active_record = {
437
- # Connection (not needed in Rails)
438
- connection: {
439
- adapter: 'sqlite3',
440
- database: 'db/mcp.sqlite3'
441
- },
442
- # Table prefix to avoid name collisions
443
- table_prefix: 'mcp_'
279
+ ```json
280
+ {
281
+ "mcpServers": {
282
+ "my-app": {
283
+ "command": "bash",
284
+ "args": ["-lc", "cd /path/to/my-app && bin/rails mcp:stdio"],
285
+ "env": { "MCP_USER": "alice@example.com" }
286
+ }
444
287
  }
445
- end
288
+ }
446
289
  ```
290
+ </details>
447
291
 
448
- In Rails applications, it uses your app's database connection automatically:
292
+ <details>
293
+ <summary><b>Cursor</b></summary>
449
294
 
450
- ```ruby
451
- # config/initializers/ruby_mcp.rb
452
- RubyMCP.configure do |config|
453
- config.storage = :active_record
454
- config.active_record = {
455
- table_prefix: "mcp_#{Rails.env}_" # Environment-specific prefix
295
+ `.cursor/mcp.json`:
296
+
297
+ ```json
298
+ {
299
+ "mcpServers": {
300
+ "my-app": {
301
+ "url": "http://localhost:3000/mcp",
302
+ "headers": { "Authorization": "Bearer YOUR_API_TOKEN" }
303
+ }
456
304
  }
457
- end
305
+ }
458
306
  ```
307
+ </details>
459
308
 
460
- The ActiveRecord adapter automatically creates the necessary tables with appropriate indexes, and handles different types of data (text, binary, JSON) appropriately.
461
-
462
- ### Custom storage
463
- You can implement custom storage backends by extending the base storage class:
309
+ <details>
310
+ <summary><b>MCP Inspector</b></summary>
464
311
 
465
- ```ruby
466
- class RedisStorage < RubyMCP::Storage::Base
467
- def initialize(options = {})
468
- super
469
- @redis = Redis.new(options)
470
- end
471
-
472
- def create_context(context)
473
- @redis.set("context:#{context.id}", JSON.dump(context.to_h))
474
- context
475
- end
476
-
477
- def get_context(context_id)
478
- data = @redis.get("context:#{context_id}")
479
- raise RubyMCP::Errors::ContextError, "Context not found: #{context_id}" unless data
480
-
481
- hash = JSON.parse(data, symbolize_names: true)
482
-
483
- # Create message objects
484
- messages = hash[:messages].map do |msg|
485
- RubyMCP::Models::Message.new(
486
- role: msg[:role],
487
- content: msg[:content],
488
- id: msg[:id],
489
- metadata: msg[:metadata]
490
- )
491
- end
492
-
493
- # Create the context
494
- RubyMCP::Models::Context.new(
495
- id: hash[:id],
496
- messages: messages,
497
- metadata: hash[:metadata]
498
- )
499
- end
500
-
501
- # Implement other required methods...
502
- end
312
+ ```bash
313
+ # Browse and call tools in the browser
314
+ npx @modelcontextprotocol/inspector bin/rails mcp:stdio
503
315
 
504
- # Configure RubyMCP to use your custom storage
505
- RubyMCP.configure do |config|
506
- config.storage = RedisStorage.new(url: ENV["REDIS_URL"])
507
- end
316
+ # Or from the command line
317
+ npx @modelcontextprotocol/inspector --cli http://localhost:3000/mcp --transport http \
318
+ --header "Authorization: Bearer $MY_APP_API_TOKEN" --method tools/list
508
319
  ```
320
+ </details>
509
321
 
510
- ## 🔒 Authentication
322
+ <details>
323
+ <summary><b>MCP registries and gateways</b></summary>
511
324
 
512
- To enable JWT authentication:
325
+ Register your `/mcp` endpoint with an MCP-compatible registry or gateway when you want discovery, access control, audit trails, and approvals managed centrally. Rails still owns the tools and enforces your app's own authorization on every call.
326
+ </details>
327
+
328
+ ## Testing
329
+
330
+ Test tools the way agents call them: through the MCP server and your real controllers.
513
331
 
514
332
  ```ruby
515
- RubyMCP.configure do |config|
516
- config.auth_required = true
517
- config.jwt_secret = ENV['JWT_SECRET']
518
- config.token_expiry = 3600 # 1 hour
333
+ # spec/rails_helper.rb
334
+ require "mcp_on_ruby/rspec"
335
+
336
+ # test/test_helper.rb
337
+ class ActiveSupport::TestCase
338
+ include McpOnRuby::TestHelper
519
339
  end
520
340
  ```
521
341
 
522
- Then, create and use JWT tokens:
523
-
524
342
  ```ruby
525
- # Generate a token
526
- require 'jwt'
343
+ it "shows agents only their own orders" do
344
+ result = mcp_call :orders_index, { status: "pending" }, headers: { "Authorization" => "Bearer #{alice.api_token}" }
527
345
 
528
- payload = {
529
- sub: "user_123",
530
- exp: Time.now.to_i + 3600
531
- }
346
+ expect(result).to be_success
347
+ expect(result.structured.pluck("id")).to eq([alice_order.id])
348
+ end
532
349
 
533
- token = JWT.encode(payload, ENV['JWT_SECRET'], 'HS256')
350
+ it "stops agents from reading other users' orders" do
351
+ result = mcp_call :orders_show, { id: bob_order.id }, as: alice # signs in through Devise/Warden
534
352
 
535
- # Use the token in requests
536
- conn = Faraday.new do |f|
537
- f.request :json
538
- f.response :json
539
- f.adapter :net_http
353
+ expect(result).to be_error
354
+ expect(result.status).to eq(404)
540
355
  end
356
+ ```
357
+
358
+ `mcp_call` returns a result with `success?`, `error?`, `structured` (the parsed JSON), `status` (for failures), `text` (what the agent reads), and `protocol_error` (unknown tool, invalid arguments). `mcp_tools` lists the tool names an agent sees.
359
+
360
+ ## Configuration
541
361
 
542
- conn.get("http://localhost:3000/contexts") do |req|
543
- req.headers["Authorization"] = "Bearer #{token}"
362
+ ```ruby
363
+ # config/initializers/mcp_on_ruby.rb
364
+ McpOnRuby.configure do |config|
365
+ config.server_name = "my-app"
366
+ config.instructions = "Tools for managing orders in My App. Amounts are in cents."
367
+ config.stdio_user = -> { User.find_by!(email: ENV.fetch("MCP_USER")) }
544
368
  end
545
369
  ```
546
370
 
547
- ## 🛠️ Development
371
+ | Option | Default | Description |
372
+ |---|---|---|
373
+ | `server_name` | app name, dasherized | Name reported to clients |
374
+ | `server_version` | `nil` | Version reported to clients |
375
+ | `instructions` | `nil` | Guidance sent to the agent when it connects |
376
+ | `stdio_user` | `nil` | Callable returning the user `mcp:stdio` sessions sign in as (Devise/Warden) |
377
+ | `stdio_headers` | `nil` | Callable returning headers `mcp:stdio` sessions send (token auth) |
378
+ | `warden_scope` | `:user` | Warden scope used for `stdio_user` and `mcp_call(as:)` |
379
+ | `allow_writes` | `true` | Set `false` to hide and refuse every `write: true` tool |
380
+ | `tools` | `[]` | Extra `MCP::Tool` classes served alongside controller tools |
381
+ | `allowed_origins` | `[]` | Browser origins allowed to call the endpoint, in addition to same-origin |
382
+ | `max_request_bytes` | 4 MiB | Largest MCP request body accepted |
383
+ | `tool_name` | `"<controller>_<action>"` | Callable `(controller_path, action)` returning default tool names |
548
384
 
549
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
385
+ ### Commands
550
386
 
551
- ### Running Tests
387
+ | Command | What it does |
388
+ |---|---|
389
+ | `bin/rails g mcp_on_ruby:install [--path=/mcp]` | Adds the initializer and mounts the endpoint |
390
+ | `bin/rails g mcp_on_ruby:expose CONTROLLER ACTION...` | Adds `mcp_tool` declarations to a controller |
391
+ | `bin/rails mcp:tools` | Lists exposed tools, their routes, and their access level |
392
+ | `bin/rails mcp:stdio` | Serves your tools over stdio for local agents |
393
+ | `bin/rails mcp:doctor` | Checks the mount, declarations, and stdio identity; exits non-zero on errors |
552
394
 
553
- ```
554
- bundle exec rspec
555
- ```
395
+ ## Compatibility
556
396
 
557
- ### Local Development Server
397
+ | | Supported |
398
+ |---|---|
399
+ | Ruby | 3.2, 3.3, 3.4 |
400
+ | Rails | 7.1, 7.2, 8.0, 8.1 |
401
+ | MCP protocol | Every version the official `mcp` gem supports, negotiated per client |
402
+ | Transports | Streamable HTTP (stateless), stdio |
403
+ | Controllers | `ActionController::Base` and `ActionController::API` |
558
404
 
559
- ```
560
- bundle exec ruby examples/simple_server/server.rb
561
- ```
405
+ CI runs the full suite on every supported Ruby and Rails combination.
406
+
407
+ ## When to use something else
562
408
 
563
- ## 🗺️ Roadmap
409
+ MCP on Ruby is for exposing an **existing Rails app** to agents. Other tools fit other jobs:
564
410
 
565
- While RubyMCP is functional for basic use cases, there are several areas planned for improvement:
411
+ - **[`mcp`](https://github.com/modelcontextprotocol/ruby-sdk)**, the official Ruby SDK, if you're building a standalone MCP server. MCP on Ruby runs on top of it.
412
+ - **[ActionMCP](https://github.com/seuros/action_mcp)** if you're on Rails 8.1+ and prefer dedicated tool, prompt, and resource classes with a full MCP feature set.
413
+ - **[Tidewave](https://tidewave.ai)** if you want coding agents to understand your codebase while you develop. MCP on Ruby is about your app's *runtime* behavior for your *users*.
566
414
 
567
- - [x] Redis persistent storage backend
568
- - [x] ActiveRecord storage backend
569
- - [ ] Complete test coverage, including integration tests
570
- - [ ] Improved error handling and recovery strategies
571
- - [ ] Rate limiting for provider APIs
572
- - [ ] Proper tokenization for context window management
573
- - [ ] More robust streaming implementation
574
- - [ ] Additional provider integrations
415
+ ## Contributing
575
416
 
576
- :heart: Contributions in any of these areas are welcome!
417
+ Bug reports, ideas, and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
418
+
419
+ ```bash
420
+ git clone https://github.com/nagstler/mcp_on_ruby && cd mcp_on_ruby
421
+ bin/setup
422
+ bundle exec rake # specs + RuboCop
423
+ RAILS_VERSION=7.2 bundle update && bundle exec rspec # another Rails series
424
+ ```
577
425
 
578
- ## 👥 Contributing
426
+ Release notes are in [CHANGELOG.md](CHANGELOG.md).
579
427
 
580
- 1. Fork the repository
581
- 2. Create your feature branch (`git checkout -b feature/my-new-feature`)
582
- 3. Commit your changes (`git commit -am 'Add some feature'`)
583
- 4. Push to the branch (`git push origin feature/my-new-feature`)
584
- 5. Create a new Pull Request
428
+ ## License
585
429
 
586
- Bug reports and pull requests are welcome on GitHub at https://github.com/nagstler/mcp_on_ruby.
430
+ Released under the [MIT License](LICENSE.txt).
587
431
 
588
- ## 📄 License
432
+ ## Acknowledgments
589
433
 
590
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
434
+ - The [Model Context Protocol](https://modelcontextprotocol.io) team for the specification.
435
+ - The maintainers of the official [Ruby SDK](https://github.com/modelcontextprotocol/ruby-sdk), on whose work this gem is built.
436
+ - The Rails community, whose conventions this gem tries hard not to break.