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.
- checksums.yaml +4 -4
- data/lib/generators/ruby_llm/mcp/{install_generator.rb → install/install_generator.rb} +4 -2
- data/lib/generators/ruby_llm/mcp/oauth/install_generator.rb +354 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/mcp_token_storage.rb.tt +114 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/user_mcp_oauth_concern.rb.tt +90 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/controllers/mcp_connections_controller.rb.tt +239 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/cleanup_expired_oauth_states_job.rb.tt +27 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/example_job.rb.tt +78 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/lib/mcp_client.rb.tt +68 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_credentials.rb.tt +19 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_states.rb.tt +21 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_credential.rb.tt +54 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_state.rb.tt +30 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/views/index.html.erb +646 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/views/show.html.erb +560 -0
- data/lib/ruby_llm/mcp/auth/browser/callback_handler.rb +71 -0
- data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +30 -0
- data/lib/ruby_llm/mcp/auth/browser/http_server.rb +115 -0
- data/lib/ruby_llm/mcp/auth/browser/opener.rb +41 -0
- data/lib/ruby_llm/mcp/auth/browser/pages.rb +539 -0
- data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +254 -0
- data/lib/ruby_llm/mcp/auth/client_registrar.rb +170 -0
- data/lib/ruby_llm/mcp/auth/discoverer.rb +124 -0
- data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +105 -0
- data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +66 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/authorization_code.rb +31 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/base.rb +31 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/client_credentials.rb +31 -0
- data/lib/ruby_llm/mcp/auth/http_response_handler.rb +65 -0
- data/lib/ruby_llm/mcp/auth/memory_storage.rb +72 -0
- data/lib/ruby_llm/mcp/auth/oauth_provider.rb +226 -0
- data/lib/ruby_llm/mcp/auth/security.rb +44 -0
- data/lib/ruby_llm/mcp/auth/session_manager.rb +56 -0
- data/lib/ruby_llm/mcp/auth/token_manager.rb +236 -0
- data/lib/ruby_llm/mcp/auth/url_builder.rb +78 -0
- data/lib/ruby_llm/mcp/auth.rb +359 -0
- data/lib/ruby_llm/mcp/client.rb +49 -0
- data/lib/ruby_llm/mcp/configuration.rb +39 -13
- data/lib/ruby_llm/mcp/coordinator.rb +11 -0
- data/lib/ruby_llm/mcp/errors.rb +11 -0
- data/lib/ruby_llm/mcp/railtie.rb +2 -10
- data/lib/ruby_llm/mcp/tool.rb +1 -1
- data/lib/ruby_llm/mcp/transport.rb +94 -1
- data/lib/ruby_llm/mcp/transports/sse.rb +116 -22
- data/lib/ruby_llm/mcp/transports/stdio.rb +4 -3
- data/lib/ruby_llm/mcp/transports/streamable_http.rb +81 -79
- data/lib/ruby_llm/mcp/version.rb +1 -1
- data/lib/ruby_llm/mcp.rb +10 -4
- metadata +40 -5
- /data/lib/generators/ruby_llm/mcp/{templates → install/templates}/initializer.rb +0 -0
- /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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e57a30998e3b5b2e2ebc5a2166c1fd0a9501cf73f8bfe1120fedf8956ba3aaf0
|
|
4
|
+
data.tar.gz: 44ff135d42ca29a24175e33f12dedd24297d86dffe3cb06a640491f300c9fa1b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
6
|
-
module
|
|
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
|