ruby_llm-mcp 0.7.1 → 0.8.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/ruby_llm/mcp/{install_generator.rb → install/install_generator.rb} +4 -2
  3. data/lib/generators/ruby_llm/mcp/oauth/install_generator.rb +354 -0
  4. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/mcp_token_storage.rb.tt +114 -0
  5. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/user_mcp_oauth_concern.rb.tt +90 -0
  6. data/lib/generators/ruby_llm/mcp/oauth/templates/controllers/mcp_connections_controller.rb.tt +239 -0
  7. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/cleanup_expired_oauth_states_job.rb.tt +27 -0
  8. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/example_job.rb.tt +78 -0
  9. data/lib/generators/ruby_llm/mcp/oauth/templates/lib/mcp_client.rb.tt +68 -0
  10. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_credentials.rb.tt +19 -0
  11. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_states.rb.tt +21 -0
  12. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_credential.rb.tt +54 -0
  13. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_state.rb.tt +30 -0
  14. data/lib/generators/ruby_llm/mcp/oauth/templates/views/index.html.erb +646 -0
  15. data/lib/generators/ruby_llm/mcp/oauth/templates/views/show.html.erb +560 -0
  16. data/lib/ruby_llm/mcp/auth/browser/callback_handler.rb +71 -0
  17. data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +30 -0
  18. data/lib/ruby_llm/mcp/auth/browser/http_server.rb +115 -0
  19. data/lib/ruby_llm/mcp/auth/browser/opener.rb +41 -0
  20. data/lib/ruby_llm/mcp/auth/browser/pages.rb +539 -0
  21. data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +254 -0
  22. data/lib/ruby_llm/mcp/auth/client_registrar.rb +170 -0
  23. data/lib/ruby_llm/mcp/auth/discoverer.rb +124 -0
  24. data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +105 -0
  25. data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +66 -0
  26. data/lib/ruby_llm/mcp/auth/grant_strategies/authorization_code.rb +31 -0
  27. data/lib/ruby_llm/mcp/auth/grant_strategies/base.rb +31 -0
  28. data/lib/ruby_llm/mcp/auth/grant_strategies/client_credentials.rb +31 -0
  29. data/lib/ruby_llm/mcp/auth/http_response_handler.rb +65 -0
  30. data/lib/ruby_llm/mcp/auth/memory_storage.rb +72 -0
  31. data/lib/ruby_llm/mcp/auth/oauth_provider.rb +226 -0
  32. data/lib/ruby_llm/mcp/auth/security.rb +44 -0
  33. data/lib/ruby_llm/mcp/auth/session_manager.rb +56 -0
  34. data/lib/ruby_llm/mcp/auth/token_manager.rb +236 -0
  35. data/lib/ruby_llm/mcp/auth/url_builder.rb +78 -0
  36. data/lib/ruby_llm/mcp/auth.rb +359 -0
  37. data/lib/ruby_llm/mcp/client.rb +49 -0
  38. data/lib/ruby_llm/mcp/configuration.rb +39 -13
  39. data/lib/ruby_llm/mcp/coordinator.rb +11 -0
  40. data/lib/ruby_llm/mcp/errors.rb +11 -0
  41. data/lib/ruby_llm/mcp/railtie.rb +2 -10
  42. data/lib/ruby_llm/mcp/tool.rb +1 -1
  43. data/lib/ruby_llm/mcp/transport.rb +94 -1
  44. data/lib/ruby_llm/mcp/transports/sse.rb +116 -22
  45. data/lib/ruby_llm/mcp/transports/stdio.rb +4 -3
  46. data/lib/ruby_llm/mcp/transports/streamable_http.rb +81 -79
  47. data/lib/ruby_llm/mcp/version.rb +1 -1
  48. data/lib/ruby_llm/mcp.rb +10 -4
  49. metadata +40 -5
  50. /data/lib/generators/ruby_llm/mcp/{templates → install/templates}/initializer.rb +0 -0
  51. /data/lib/generators/ruby_llm/mcp/{templates → install/templates}/mcps.yml +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b40509c28a60e1ae01ed83d47503d76f30d5f3bf9852ef4c30ca033d6ea149bc
4
- data.tar.gz: f3393dc6423c6bcda143cb6b22e7042434ca1d7d385cc2adf97ba925efff6912
3
+ metadata.gz: e57a30998e3b5b2e2ebc5a2166c1fd0a9501cf73f8bfe1120fedf8956ba3aaf0
4
+ data.tar.gz: 44ff135d42ca29a24175e33f12dedd24297d86dffe3cb06a640491f300c9fa1b
5
5
  SHA512:
6
- metadata.gz: 86e6dd3009df055ad4fa6d088b6b1d9fb87cc8c6244b1436e4ef96ef1cbd822c321c779bd518557544a6c457d19270bf1a57c874cb6d80543141ecac7c370754
7
- data.tar.gz: 5a76f9b2aaf2d3db38d97c9d66341458e25f3cdd89fdaf1768095a1bba20257154e128c9b60245f1f09f3e6f09b967daacd70748008c4edf90c3e836546bd6d6
6
+ metadata.gz: fc2325d76bfbca79d8f99b304b05816a80e4414dc6ea359d92556abeb391019ff02cdd6380fe84f0d2767ad82632c434424b2fdee77c5ff2d1cc78dce879a2ac
7
+ data.tar.gz: ba9b9d1bff3f0ab545a22ae27075a5b6d63a40393c3da5daa437c25340019622b258e4f621cc08f3f8d90565bd720c234a55d502df568b5e5bc6abcce5b4662e
@@ -2,12 +2,14 @@
2
2
 
3
3
  require "rails/generators/base"
4
4
 
5
- module RubyLlm
6
- module Mcp
5
+ module RubyLLM
6
+ module MCP
7
7
  module Generators
8
8
  class InstallGenerator < Rails::Generators::Base
9
9
  source_root File.expand_path("templates", __dir__)
10
10
 
11
+ namespace "ruby_llm:mcp:install"
12
+
11
13
  desc "Install RubyLLM MCP configuration files"
12
14
 
13
15
  def create_initializer
@@ -0,0 +1,354 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "rails/generators/active_record"
5
+
6
+ module RubyLLM
7
+ module MCP
8
+ module OAuth
9
+ module Generators
10
+ class InstallGenerator < Rails::Generators::Base
11
+ include Rails::Generators::Migration
12
+
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ namespace "ruby_llm:mcp:oauth"
16
+
17
+ argument :user_model, type: :string, default: "User", banner: "UserModel",
18
+ desc: "The name of the user model (default: User)"
19
+
20
+ class_option :namespace, type: :string, default: nil,
21
+ desc: "Namespace for generated files (e.g., Admin)"
22
+ class_option :controller_name, type: :string, default: "McpConnectionsController",
23
+ desc: "Name of the controller to generate"
24
+ class_option :skip_routes, type: :boolean, default: false,
25
+ desc: "Skip adding routes to config/routes.rb"
26
+ class_option :skip_views, type: :boolean, default: false,
27
+ desc: "Skip generating view files"
28
+
29
+ desc "Install RubyLLM MCP OAuth configuration for multi-user authentication\n" \
30
+ "Usage: rails g ruby_llm:mcp:oauth:install [UserModel] [options]"
31
+
32
+ def self.next_migration_number(dirname)
33
+ ::ActiveRecord::Generators::Base.next_migration_number(dirname)
34
+ end
35
+
36
+ # Validation methods
37
+ def check_dependencies
38
+ check_user_model_exists
39
+ check_rails_version
40
+ check_encryption_configured
41
+ end
42
+
43
+ def create_migrations
44
+ create_migration_if_needed(
45
+ "create_mcp_oauth_credentials",
46
+ "migrations/create_mcp_oauth_credentials.rb.tt"
47
+ )
48
+ create_migration_if_needed(
49
+ "create_mcp_oauth_states",
50
+ "migrations/create_mcp_oauth_states.rb.tt"
51
+ )
52
+ end
53
+
54
+ def create_models
55
+ template "models/mcp_oauth_credential.rb.tt", "app/models/mcp_oauth_credential.rb"
56
+ template "models/mcp_oauth_state.rb.tt", "app/models/mcp_oauth_state.rb"
57
+ end
58
+
59
+ def create_token_storage_concern
60
+ template "concerns/mcp_token_storage.rb.tt", "app/models/concerns/mcp_token_storage.rb"
61
+ end
62
+
63
+ def create_mcp_client
64
+ template "lib/mcp_client.rb.tt", "app/lib/mcp_client.rb"
65
+ end
66
+
67
+ def create_controller
68
+ controller_path = if namespace_name
69
+ "#{namespace_name.underscore}/mcp_connections_controller.rb"
70
+ else
71
+ "mcp_connections_controller.rb"
72
+ end
73
+ template "controllers/mcp_connections_controller.rb.tt", "app/controllers/#{controller_path}"
74
+ end
75
+
76
+ def add_routes
77
+ return if options[:skip_routes]
78
+
79
+ routes_content = if namespace_name
80
+ <<~ROUTES.strip
81
+ namespace :#{namespace_name.underscore} do
82
+ resources :mcp_connections, only: [ :index, :show, :create, :update, :destroy ] do
83
+ collection do
84
+ get :callback
85
+ end
86
+ end
87
+ end
88
+ ROUTES
89
+ else
90
+ <<~ROUTES.strip
91
+ resources :mcp_connections, only: [ :index, :show, :create, :update, :destroy ] do
92
+ collection do
93
+ get :callback
94
+ end
95
+ end
96
+ ROUTES
97
+ end
98
+
99
+ route routes_content
100
+ end
101
+
102
+ def create_views
103
+ return if options[:skip_views]
104
+
105
+ view_path = namespace_name ? "#{namespace_name.underscore}/mcp_connections" : "mcp_connections"
106
+ copy_file "views/index.html.erb", "app/views/#{view_path}/index.html.erb"
107
+ copy_file "views/show.html.erb", "app/views/#{view_path}/show.html.erb"
108
+ end
109
+
110
+ def add_user_concern
111
+ template "concerns/user_mcp_oauth_concern.rb.tt", "app/models/concerns/user_mcp_oauth.rb"
112
+ end
113
+
114
+ def inject_concern_into_user_model
115
+ user_model_path = "app/models/#{user_model_name.underscore}.rb"
116
+ return unless File.exist?(user_model_path)
117
+
118
+ # Check if concern is already included
119
+ if File.read(user_model_path).include?("include UserMcpOauth")
120
+ say " ⏭️ UserMcpOauth concern already included in #{user_model_name}", :yellow
121
+ return
122
+ end
123
+
124
+ inject_into_class user_model_path, user_model_name do
125
+ " include UserMcpOauth\n"
126
+ end
127
+
128
+ say " ✅ Added UserMcpOauth concern to #{user_model_name}", :green
129
+ rescue StandardError => e
130
+ say " ⚠️ Could not automatically add concern to #{user_model_name}: #{e.message}", :yellow
131
+ say " Please manually add: include UserMcpOauth", :yellow
132
+ end
133
+
134
+ def create_example_job
135
+ template "jobs/example_job.rb.tt", "app/jobs/ai_research_job.rb"
136
+ end
137
+
138
+ def create_cleanup_job
139
+ template "jobs/cleanup_expired_oauth_states_job.rb.tt", "app/jobs/cleanup_expired_oauth_states_job.rb"
140
+ end
141
+
142
+ def display_readme
143
+ return unless behavior == :invoke
144
+
145
+ display_header
146
+ display_created_files
147
+ display_next_steps
148
+ display_documentation_links
149
+ display_usage_example
150
+ end
151
+
152
+ private
153
+
154
+ # Migration helper method
155
+ def create_migration_if_needed(migration_name, template_path)
156
+ if migration_exists?(migration_name)
157
+ say " ⏭️ Migration #{migration_name} already exists, skipping", :yellow
158
+ else
159
+ migration_template template_path, "db/migrate/#{migration_name}.rb"
160
+ end
161
+ end
162
+
163
+ def migration_exists?(migration_name)
164
+ Dir.glob("db/migrate/*_#{migration_name}.rb").any?
165
+ end
166
+
167
+ # Validation methods
168
+ def check_user_model_exists
169
+ user_model_path = "app/models/#{user_model_name.underscore}.rb"
170
+ return if File.exist?(user_model_path)
171
+
172
+ say "\n⚠️ Warning: #{user_model_name} model not found at #{user_model_path}", :yellow
173
+ say "The generator will continue, but you'll need to:", :yellow
174
+ say " 1. Create the #{user_model_name} model", :yellow
175
+ say " 2. Include the UserMcpOauth concern in your #{user_model_name} model", :yellow
176
+ say "\n"
177
+ end
178
+
179
+ def check_rails_version
180
+ return if defined?(Rails) && Rails::VERSION::MAJOR >= 7
181
+
182
+ say "\n⚠️ Warning: Rails 7.0+ recommended for this generator", :yellow
183
+ say "Current Rails version: #{Rails::VERSION::STRING}", :yellow
184
+ say "\n"
185
+ end
186
+
187
+ def check_encryption_configured
188
+ return unless defined?(ActiveRecord::Encryption)
189
+
190
+ if ActiveRecord::Encryption.config.primary_key.blank?
191
+ say "\n🔐 ActiveRecord::Encryption not configured. Generating encryption keys...", :yellow
192
+
193
+ begin
194
+ # Run rails db:encryption:init to generate keys
195
+ run "bin/rails db:encryption:init", verbose: false
196
+
197
+ say "✅ Encryption keys generated successfully!", :green
198
+ say " Keys have been added to your credentials file.", :green
199
+ say " ⚠️ Important: Restart your Rails server for changes to take effect.", :yellow
200
+ say "\n"
201
+ rescue StandardError => e
202
+ say "\n⚠️ Warning: Could not automatically generate encryption keys", :yellow
203
+ say "Error: #{e.message}", :yellow
204
+ say "Please run manually: rails db:encryption:init", :yellow
205
+ say "Then add the generated keys to your credentials or environment variables", :yellow
206
+ say "\n"
207
+ end
208
+ end
209
+ rescue StandardError => e
210
+ # Encryption config check failed, but we'll continue
211
+ say "\n⚠️ Could not check encryption configuration: #{e.message}", :yellow
212
+ say "You may need to run: rails db:encryption:init", :yellow
213
+ say "\n"
214
+ end
215
+
216
+ # Helper methods for template variables
217
+ def user_model_name
218
+ @user_model_name ||= user_model.camelize
219
+ end
220
+
221
+ def user_table_name
222
+ @user_table_name ||= user_model_name.underscore.pluralize
223
+ end
224
+
225
+ def user_variable_name
226
+ @user_variable_name ||= user_model_name.underscore
227
+ end
228
+
229
+ def controller_class_name
230
+ @controller_class_name ||= if namespace_name
231
+ "#{namespace_name}::#{options[:controller_name]}"
232
+ else
233
+ options[:controller_name]
234
+ end
235
+ end
236
+
237
+ def controller_file_path
238
+ @controller_file_path ||= if namespace_name
239
+ "#{namespace_name.underscore}/#{controller_name.underscore}"
240
+ else
241
+ controller_name.underscore
242
+ end
243
+ end
244
+
245
+ def controller_name
246
+ @controller_name ||= options[:controller_name].sub(/Controller$/, "")
247
+ end
248
+
249
+ def current_user_method
250
+ @current_user_method ||= "current_#{user_variable_name}"
251
+ end
252
+
253
+ def authenticate_method
254
+ @authenticate_method ||= "authenticate_#{user_variable_name}!"
255
+ end
256
+
257
+ def credential_model_name
258
+ @credential_model_name ||= "McpOauthCredential"
259
+ end
260
+
261
+ def credential_table_name
262
+ @credential_table_name ||= "mcp_oauth_credentials"
263
+ end
264
+
265
+ def state_model_name
266
+ @state_model_name ||= "McpOauthState"
267
+ end
268
+
269
+ def state_table_name
270
+ @state_table_name ||= "mcp_oauth_states"
271
+ end
272
+
273
+ def namespace_name
274
+ @namespace_name ||= options[:namespace]
275
+ end
276
+
277
+ def concern_module_name
278
+ @concern_module_name ||= "UserMcpOauth"
279
+ end
280
+
281
+ # Display methods
282
+ def display_header
283
+ say "\n"
284
+ say "=" * 70, :green
285
+ say "✅ RubyLLM MCP OAuth installed successfully!", :green
286
+ say "=" * 70, :green
287
+ say "\n"
288
+ end
289
+
290
+ def display_created_files
291
+ say "📦 Created files:", :blue
292
+ say " • db/migrate/..._create_mcp_oauth_credentials.rb"
293
+ say " • db/migrate/..._create_mcp_oauth_states.rb"
294
+ say " • app/models/mcp_oauth_credential.rb"
295
+ say " • app/models/mcp_oauth_state.rb"
296
+ say " • app/models/concerns/mcp_token_storage.rb"
297
+ say " • app/models/concerns/user_mcp_oauth.rb"
298
+
299
+ # Show if concern was injected
300
+ user_model_path = "app/models/#{user_model_name.underscore}.rb"
301
+ if File.exist?(user_model_path) && File.read(user_model_path).include?("include UserMcpOauth")
302
+ say " • app/models/#{user_model_name.underscore}.rb (concern added)", :green
303
+ end
304
+
305
+ say " • app/lib/mcp_client.rb"
306
+ say " • app/controllers/#{controller_file_path}_controller.rb"
307
+ unless options[:skip_views]
308
+ say " • app/views/#{"#{namespace_name.underscore}/" if namespace_name}mcp_connections/index.html.erb"
309
+ say " • app/views/#{"#{namespace_name.underscore}/" if namespace_name}mcp_connections/show.html.erb"
310
+ end
311
+ say " • app/jobs/ai_research_job.rb (example)"
312
+ say " • app/jobs/cleanup_expired_oauth_states_job.rb"
313
+ say "\n"
314
+ end
315
+
316
+ def display_next_steps
317
+ say "📝 Next steps:", :yellow
318
+
319
+ routes_status = options[:skip_routes] ? "not added - add them manually" : "added automatically"
320
+ say " 1. Routes #{routes_status}"
321
+ say " 2. Run migrations: rails db:migrate"
322
+
323
+ # Only show concern instruction if user model doesn't exist
324
+ user_model_path = "app/models/#{user_model_name.underscore}.rb"
325
+ unless File.exist?(user_model_path)
326
+ say " 3. Include concern in #{user_model_name}: include UserMcpOauth"
327
+ end
328
+
329
+ connections_path = namespace_name ? "#{namespace_name.underscore}/mcp_connections" : "mcp_connections"
330
+ say " #{File.exist?(user_model_path) ? '3' : '4'}. Visit /#{connections_path} \
331
+ and enter your MCP server URL to connect"
332
+ say "\n"
333
+ end
334
+
335
+ def display_documentation_links
336
+ say "📚 Documentation:", :cyan
337
+ say " • OAuth Guide: docs/guides/rails-oauth.md"
338
+ say " • Full OAuth Docs: docs/guides/oauth.md"
339
+ say " • Online: https://www.rubyllm-mcp.com/guides/rails-oauth"
340
+ say "\n"
341
+ end
342
+
343
+ def display_usage_example
344
+ say "💡 Usage Examples:", :blue
345
+ say " client = McpClient.for(#{user_variable_name}, server_url: 'https://mcp.example.com')"
346
+ say " or: #{user_variable_name}.mcp_client(server_url: 'https://mcp.example.com')"
347
+ say "⭐ Star us: https://github.com/patvice/ruby_llm-mcp", :magenta
348
+ say "\n"
349
+ end
350
+ end
351
+ end
352
+ end
353
+ end
354
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Per-<%= user_variable_name %> OAuth token storage for RubyLLM MCP
4
+ # Implements the storage interface required by RubyLLM::MCP::Auth::OAuthProvider
5
+ #
6
+ # Include this in your <%= user_model_name %> model (or any model that needs MCP OAuth):
7
+ # include McpTokenStorage
8
+ module McpTokenStorage
9
+ extend ActiveSupport::Concern
10
+
11
+ # Create token storage instance for a specific server URL
12
+ # @param server_url [String] MCP server URL
13
+ # @param name [String] Optional friendly name for the server
14
+ # @param scope [String] Optional OAuth scope for the connection
15
+ # @return [TokenStorageAdapter] storage adapter instance
16
+ def mcp_token_storage(server_url, name = nil, scope = nil)
17
+ TokenStorageAdapter.new(self.id, server_url, self.class.name.underscore, name, scope)
18
+ end
19
+
20
+ # Internal adapter class that implements the OAuth storage interface
21
+ class TokenStorageAdapter
22
+ def initialize(<%= user_variable_name %>_id, server_url, user_type = "<%= user_variable_name %>", name = nil, scope = nil)
23
+ @<%= user_variable_name %>_id = <%= user_variable_name %>_id
24
+ @server_url = server_url
25
+ @user_type = user_type
26
+ @name = name
27
+ @scope = scope
28
+ end
29
+
30
+ # Token storage
31
+ def get_token(_server_url)
32
+ credential = <%= credential_model_name %>.find_by(<%= user_table_name.singularize %>_id: @<%= user_variable_name %>_id, server_url: @server_url)
33
+ credential&.token
34
+ end
35
+
36
+ def set_token(_server_url, token)
37
+ credential = <%= credential_model_name %>.find_or_initialize_by(
38
+ <%= user_table_name.singularize %>_id: @<%= user_variable_name %>_id,
39
+ server_url: @server_url
40
+ )
41
+ credential.name = @name if @name.present?
42
+ credential.token = token
43
+ credential.last_refreshed_at = Time.current
44
+ credential.save!
45
+ end
46
+
47
+ # Client registration storage
48
+ def get_client_info(_server_url)
49
+ credential = <%= credential_model_name %>.find_by(<%= user_table_name.singularize %>_id: @<%= user_variable_name %>_id, server_url: @server_url)
50
+ credential&.client_info
51
+ end
52
+
53
+ def set_client_info(_server_url, client_info)
54
+ credential = <%= credential_model_name %>.find_or_initialize_by(
55
+ <%= user_table_name.singularize %>_id: @<%= user_variable_name %>_id,
56
+ server_url: @server_url
57
+ )
58
+ credential.name = @name if @name.present?
59
+ credential.client_info = client_info
60
+ credential.save!
61
+ end
62
+
63
+ # Server metadata caching (shared across users)
64
+ def get_server_metadata(server_url)
65
+ Rails.cache.fetch("mcp:server_metadata:#{server_url}", expires_in: 24.hours) do
66
+ nil
67
+ end
68
+ end
69
+
70
+ def set_server_metadata(server_url, metadata)
71
+ Rails.cache.write("mcp:server_metadata:#{server_url}", metadata, expires_in: 24.hours)
72
+ end
73
+
74
+ # PKCE state management (temporary - per <%= user_variable_name %>)
75
+ def get_pkce(_server_url)
76
+ state = <%= state_model_name %>.find_by(<%= user_table_name.singularize %>_id: @<%= user_variable_name %>_id, server_url: @server_url)
77
+ return nil unless state
78
+
79
+ state.pkce
80
+ end
81
+
82
+ def set_pkce(_server_url, pkce)
83
+ state = <%= state_model_name %>.find_or_initialize_by(<%= user_table_name.singularize %>_id: @<%= user_variable_name %>_id, server_url: @server_url)
84
+ state.pkce = pkce
85
+ state.state_param ||= SecureRandom.hex(32)
86
+ state.name = @name if @name.present?
87
+ state.scope = @scope if @scope.present?
88
+ state.expires_at = 10.minutes.from_now
89
+ state.save!
90
+ end
91
+
92
+ def delete_pkce(_server_url)
93
+ <%= state_model_name %>.where(<%= user_table_name.singularize %>_id: @<%= user_variable_name %>_id, server_url: @server_url).delete_all
94
+ end
95
+
96
+ # State parameter management
97
+ def get_state(_server_url)
98
+ <%= state_model_name %>.find_by(<%= user_table_name.singularize %>_id: @<%= user_variable_name %>_id, server_url: @server_url)&.state_param
99
+ end
100
+
101
+ def set_state(_server_url, state_param)
102
+ state = <%= state_model_name %>.find_or_initialize_by(<%= user_table_name.singularize %>_id: @<%= user_variable_name %>_id, server_url: @server_url)
103
+ state.state_param = state_param
104
+ state.name = @name if @name.present?
105
+ state.scope = @scope if @scope.present?
106
+ state.expires_at = 10.minutes.from_now
107
+ state.save!
108
+ end
109
+
110
+ def delete_state(_server_url)
111
+ <%= state_model_name %>.where(<%= user_table_name.singularize %>_id: @<%= user_variable_name %>_id, server_url: @server_url).delete_all
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Add this to your User model:
4
+ # include UserMcpOauth
5
+
6
+ module UserMcpOauth
7
+ extend ActiveSupport::Concern
8
+
9
+ include McpTokenStorage
10
+
11
+ included do
12
+ has_many :mcp_oauth_credentials, dependent: :destroy
13
+ has_many :mcp_oauth_states, dependent: :destroy
14
+ end
15
+
16
+ # Check if user has connected to a specific MCP server
17
+ # @param server_url [String] MCP server URL (required)
18
+ # @return [Boolean] true if user has an OAuth credential for this server
19
+ def mcp_connected?(server_url)
20
+ return false unless server_url.present?
21
+
22
+ mcp_oauth_credentials.exists?(server_url: server_url)
23
+ end
24
+
25
+ # Get valid token for a server
26
+ # @param server_url [String] MCP server URL (required)
27
+ # @return [RubyLLM::MCP::Auth::Token, nil] token if valid, nil otherwise
28
+ def mcp_token_for(server_url)
29
+ return nil unless server_url.present?
30
+
31
+ credential = mcp_oauth_credentials.find_by(server_url: server_url)
32
+ return nil unless credential
33
+
34
+ token = credential.token
35
+ return nil if token.nil? || token.expired? || token.expires_soon?
36
+
37
+ token
38
+ end
39
+
40
+ # Get MCP client for this user
41
+ # @param server_url [String] MCP server URL (required)
42
+ # @return [RubyLLM::MCP::Client] configured client
43
+ # @raise [McpClient::NotAuthenticatedError] if not connected
44
+ def mcp_client(server_url:)
45
+ McpClient.for(self, server_url: server_url)
46
+ end
47
+
48
+ # Get MCP client with fallback to nil if not authenticated
49
+ # @param server_url [String] MCP server URL (required)
50
+ # @return [RubyLLM::MCP::Client, nil] client or nil
51
+ def mcp_client_safe(server_url:)
52
+ McpClient.for_with_fallback(self, server_url: server_url)
53
+ end
54
+
55
+ # Get all connected MCP servers for this user
56
+ # @return [Array<String>] array of server URLs
57
+ def connected_mcp_servers
58
+ mcp_oauth_credentials.pluck(:server_url)
59
+ end
60
+
61
+ # Disconnect from a specific MCP server
62
+ # @param server_url [String] MCP server URL
63
+ def revoke_mcp_connection(server_url)
64
+ credential = mcp_oauth_credentials.find_by(server_url: server_url)
65
+ credential&.destroy
66
+ end
67
+
68
+ # Get OAuth connection status for a server
69
+ # @param server_url [String] MCP server URL (required)
70
+ # @return [Hash] status information
71
+ def mcp_connection_status(server_url)
72
+ return { connected: false } unless server_url.present?
73
+
74
+ credential = mcp_oauth_credentials.find_by(server_url: server_url)
75
+
76
+ return { connected: false } unless credential
77
+
78
+ token = credential.token
79
+
80
+ {
81
+ connected: true,
82
+ valid: token && !token.expired?,
83
+ expires_at: token&.expires_at,
84
+ expires_soon: token&.expires_soon?,
85
+ has_refresh_token: token&.refresh_token.present?,
86
+ last_refreshed_at: credential.last_refreshed_at,
87
+ scope: token&.scope
88
+ }
89
+ end
90
+ end