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
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Controller for managing MCP OAuth connections
4
+ <% if namespace_name -%>
5
+ class <%= namespace_name %>::McpConnectionsController < ApplicationController
6
+ <% else -%>
7
+ class McpConnectionsController < ApplicationController
8
+ <% end -%>
9
+ include ActionView::Helpers::DateHelper
10
+
11
+ before_action :<%= authenticate_method %>
12
+
13
+ OAUTH_FLOW_TIMEOUT_SECONDS = 10.minutes.to_i
14
+
15
+ def index
16
+ @credentials = <%= current_user_method %>.mcp_oauth_credentials.order(created_at: :desc)
17
+ end
18
+
19
+ def show
20
+ @credential = <%= current_user_method %>.mcp_oauth_credentials.find(params[:id])
21
+
22
+ begin
23
+ client = McpClient.for(<%= current_user_method %>, server_url: @credential.server_url)
24
+ @tools = client.tools
25
+
26
+ Rails.logger.info "Loaded #{@tools.count} tools from MCP server: #{@credential.server_url}"
27
+ rescue McpClient::NotAuthenticatedError => e
28
+ Rails.logger.error "MCP authentication error: #{e.message}"
29
+ redirect_to mcp_connections_path,
30
+ alert: "Authentication failed. Please refresh your connection."
31
+ nil
32
+ rescue StandardError => e
33
+ Rails.logger.error "Failed to retrieve MCP tools: #{e.message}"
34
+ redirect_to mcp_connections_path,
35
+ alert: "Failed to connect to MCP server: #{e.message}"
36
+ nil
37
+ ensure
38
+ client&.stop
39
+ end
40
+ rescue ActiveRecord::RecordNotFound
41
+ redirect_to mcp_connections_path,
42
+ alert: "Connection not found"
43
+ end
44
+
45
+ def create
46
+ name = params[:name]
47
+ server_url = params[:server_url]
48
+
49
+ unless name.present?
50
+ redirect_to mcp_connections_path, alert: "Please provide a server name"
51
+ return
52
+ end
53
+
54
+ unless server_url.present?
55
+ redirect_to mcp_connections_path, alert: "Please provide an MCP server URL"
56
+ return
57
+ end
58
+
59
+ scope = params[:scope] || "mcp:read mcp:write"
60
+
61
+ # Create OAuth provider with user's token storage
62
+ # Storage will automatically save name and scope to McpOauthState table
63
+ oauth_provider = RubyLLM::MCP::Auth::OAuthProvider.new(
64
+ server_url: server_url,
65
+ redirect_uri: mcp_connections_callback_url,
66
+ scope: scope,
67
+ storage: token_storage(server_url, name, scope),
68
+ logger: Rails.logger
69
+ )
70
+
71
+ # Start OAuth flow
72
+ begin
73
+ auth_url = oauth_provider.start_authorization_flow
74
+ # OAuth state (including name and scope) is now stored in database via token_storage
75
+ # No need to use session - database persists across redirects
76
+
77
+ redirect_to auth_url, allow_other_host: true
78
+ rescue StandardError => e
79
+ Rails.logger.error "MCP OAuth flow start failed: #{e.message}"
80
+ redirect_to mcp_connections_path,
81
+ alert: "Failed to start OAuth flow: #{e.message}"
82
+ end
83
+ end
84
+
85
+ def update
86
+ credential = <%= current_user_method %>.mcp_oauth_credentials.find(params[:id])
87
+
88
+ oauth_provider = RubyLLM::MCP::Auth::OAuthProvider.new(
89
+ server_url: credential.server_url,
90
+ storage: token_storage(credential.server_url, credential.name, credential.token&.scope),
91
+ logger: Rails.logger
92
+ )
93
+
94
+ refreshed_token = oauth_provider.access_token
95
+
96
+ if refreshed_token
97
+ credential.update!(last_refreshed_at: Time.current)
98
+
99
+ Rails.logger.info "Token refreshed for <%= user_variable_name %> #{<%= current_user_method %>.id}, server: #{credential.server_url}"
100
+
101
+ redirect_to mcp_connections_path,
102
+ notice: "Token refreshed successfully. Expires #{time_ago_in_words(credential.token_expires_at)} from now."
103
+ else
104
+ Rails.logger.warn "Token refresh failed for <%= user_variable_name %> #{<%= current_user_method %>.id}, server: #{credential.server_url}"
105
+
106
+ redirect_to mcp_connections_path,
107
+ alert: "Token refresh failed. Please reconnect to authorize again."
108
+ end
109
+ rescue ActiveRecord::RecordNotFound
110
+ redirect_to mcp_connections_path,
111
+ alert: "Connection not found"
112
+ rescue StandardError => e
113
+ Rails.logger.error "Token refresh error: #{e.message}"
114
+ redirect_to mcp_connections_path,
115
+ alert: "Token refresh failed: #{e.message}"
116
+ end
117
+
118
+ def destroy
119
+ credential = <%= current_user_method %>.mcp_oauth_credentials.find(params[:id])
120
+ server_url = credential.server_url
121
+ credential.destroy
122
+
123
+ Rails.logger.info "<%= user_model_name %> #{<%= current_user_method %>.id} disconnected from MCP server: #{server_url}"
124
+
125
+ redirect_to mcp_connections_path,
126
+ notice: "MCP server disconnected successfully"
127
+ rescue ActiveRecord::RecordNotFound
128
+ redirect_to mcp_connections_path,
129
+ alert: "Connection not found"
130
+ end
131
+
132
+ def callback
133
+ return if oauth_error_present?
134
+
135
+ oauth_state = retrieve_and_validate_oauth_state
136
+ return unless oauth_state
137
+
138
+ complete_oauth_flow_for_user(oauth_state)
139
+ end
140
+
141
+ private
142
+
143
+ def retrieve_and_validate_oauth_state
144
+ # Look up OAuth state from database using the state parameter from callback
145
+ state_param = params[:state]
146
+
147
+ unless state_param
148
+ redirect_to mcp_connections_path, alert: "Missing OAuth state parameter"
149
+ return nil
150
+ end
151
+
152
+ oauth_state = <%= current_user_method %>.mcp_oauth_states.find_by(state_param: state_param)
153
+
154
+ unless oauth_state
155
+ redirect_to mcp_connections_path, alert: "OAuth state not found. Please try again."
156
+ return nil
157
+ end
158
+
159
+ if oauth_state.expires_at < Time.current
160
+ oauth_state.destroy
161
+ redirect_to mcp_connections_path, alert: "OAuth flow timed out. Please try again."
162
+ return nil
163
+ end
164
+
165
+ oauth_state
166
+ end
167
+
168
+ def oauth_error_present?
169
+ return false unless params[:error]
170
+
171
+ error_message = params[:error_description] || params[:error]
172
+ redirect_to mcp_connections_path, alert: "OAuth authorization failed: #{error_message}"
173
+ true
174
+ end
175
+
176
+ def complete_oauth_flow_for_user(oauth_state)
177
+ oauth_provider = create_oauth_provider_from_state(oauth_state)
178
+
179
+ oauth_provider.complete_authorization_flow(params[:code], params[:state])
180
+
181
+ # Clean up the OAuth state record after successful completion
182
+ oauth_state.destroy
183
+
184
+ log_successful_oauth(oauth_state)
185
+ redirect_after_success
186
+ rescue StandardError => e
187
+ handle_oauth_callback_error(e, oauth_state)
188
+ end
189
+
190
+ def create_oauth_provider_from_state(oauth_state)
191
+ RubyLLM::MCP::Auth::OAuthProvider.new(
192
+ server_url: oauth_state.server_url,
193
+ redirect_uri: mcp_connections_callback_url,
194
+ scope: oauth_state.scope,
195
+ storage: token_storage(oauth_state.server_url, oauth_state.name, oauth_state.scope),
196
+ logger: Rails.logger
197
+ )
198
+ end
199
+
200
+ def log_successful_oauth(oauth_state)
201
+ Rails.logger.info "MCP OAuth completed for <%= user_variable_name %> #{<%= current_user_method %>.id}, " \
202
+ "server: #{oauth_state.server_url}"
203
+ end
204
+
205
+ def redirect_after_success
206
+ redirect_to mcp_connections_path, notice: "Successfully connected to MCP server!"
207
+ end
208
+
209
+ def handle_oauth_callback_error(error, oauth_state = nil)
210
+ Rails.logger.error "MCP OAuth callback failed: #{error.message}"
211
+ oauth_state&.destroy # Clean up failed state
212
+ redirect_to mcp_connections_path, alert: "OAuth authorization failed: #{error.message}"
213
+ end
214
+
215
+ def token_storage(server_url, name = nil, scope = nil)
216
+ <%= current_user_method %>.mcp_token_storage(server_url, name, scope)
217
+ end
218
+
219
+ def mcp_connections_callback_url
220
+ <% if namespace_name -%>
221
+ callback_<%= namespace_name.underscore %>_mcp_connections_url
222
+ <% else -%>
223
+ callback_mcp_connections_url
224
+ <% end -%>
225
+ end
226
+ <% if namespace_name -%>
227
+
228
+ # Route helper methods for namespaced routes
229
+ def mcp_connections_path
230
+ <%= namespace_name.underscore %>_mcp_connections_path
231
+ end
232
+
233
+ def mcp_connection_path(credential)
234
+ <%= namespace_name.underscore %>_mcp_connection_path(credential)
235
+ end
236
+
237
+ helper_method :mcp_connections_path, :mcp_connection_path
238
+ <% end -%>
239
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Background job to cleanup expired OAuth states
4
+ # Schedule this to run hourly or daily to prevent state table bloat
5
+ #
6
+ # Example with sidekiq-scheduler:
7
+ # cleanup_expired_states:
8
+ # cron: '0 * * * *' # Every hour
9
+ # class: CleanupExpiredOauthStatesJob
10
+ #
11
+ # Example with whenever gem:
12
+ # every 1.hour do
13
+ # runner "CleanupExpiredOauthStatesJob.perform_later"
14
+ # end
15
+ class CleanupExpiredOauthStatesJob < ApplicationJob
16
+ queue_as :default
17
+
18
+ # Cleanup expired OAuth flow states
19
+ # States are temporary and only needed during the OAuth flow (typically < 10 minutes)
20
+ def perform
21
+ deleted_count = <%= state_model_name %>.cleanup_expired
22
+
23
+ Rails.logger.info "Cleaned up #{deleted_count} expired MCP OAuth states"
24
+
25
+ deleted_count
26
+ end
27
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Example background job using MCP with per-<%= user_variable_name %> OAuth
4
+ class AiResearchJob < ApplicationJob
5
+ queue_as :default
6
+
7
+ # Retry on authentication errors (<%= user_variable_name %> might reconnect)
8
+ retry_on McpClient::NotAuthenticatedError,
9
+ wait: 1.hour,
10
+ attempts: 3 do |job, _exception|
11
+ # After retries exhausted, notify <%= user_variable_name %>
12
+ <%= user_variable_name %> = <%= user_model_name %>.find(job.arguments.first)
13
+ <%= user_model_name %>Mailer.mcp_auth_required(<%= user_variable_name %>).deliver_now
14
+ end
15
+
16
+ # Retry on transient network errors
17
+ retry_on RubyLLM::MCP::Errors::TransportError,
18
+ wait: :exponentially_longer,
19
+ attempts: 5
20
+
21
+ # Perform AI research using <%= user_variable_name %>'s MCP connection
22
+ # @param <%= user_variable_name %>_id [Integer] ID of the <%= user_variable_name %>
23
+ # @param server_url [String] MCP server URL to use
24
+ # @param query [String] research query
25
+ # @param options [Hash] additional options
26
+ def perform(<%= user_variable_name %>_id, server_url, query, options = {})
27
+ <%= user_variable_name %> = <%= user_model_name %>.find(<%= user_variable_name %>_id)
28
+
29
+ # Create MCP client with <%= user_variable_name %>'s OAuth token
30
+ client = McpClient.for(<%= user_variable_name %>, server_url: server_url)
31
+
32
+ begin
33
+ # Get tools with <%= user_variable_name %>'s permissions
34
+ tools = client.tools
35
+ Rails.logger.info "Loaded #{tools.count} MCP tools for <%= user_variable_name %> #{<%= user_variable_name %>_id}"
36
+
37
+ # Create AI chat with <%= user_variable_name %>'s MCP context
38
+ chat = RubyLLM.chat(provider: options[:provider] || "anthropic/claude-sonnet-4")
39
+ .with_tools(*tools)
40
+
41
+ # Execute research
42
+ response = chat.ask(query)
43
+
44
+ # Save results
45
+ save_research_results(<%= user_variable_name %>, query, response)
46
+
47
+ # Notify <%= user_variable_name %> of completion
48
+ notify_completion(<%= user_variable_name %>, query)
49
+
50
+ Rails.logger.info "Completed AI research for <%= user_variable_name %> #{<%= user_variable_name %>_id}"
51
+ ensure
52
+ # Always close client connection
53
+ client&.stop
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def save_research_results(<%= user_variable_name %>, query, response)
60
+ # Customize based on your schema
61
+ <%= user_variable_name %>.research_results.create!(
62
+ query: query,
63
+ result: response.text,
64
+ completed_at: Time.current
65
+ )
66
+ end
67
+
68
+ def notify_completion(<%= user_variable_name %>, query)
69
+ # Notify via email, ActionCable, or other mechanism
70
+ ActionCable.server.broadcast(
71
+ "<%= user_variable_name %>_#{<%= user_variable_name %>.id}_notifications",
72
+ {
73
+ type: "research_complete",
74
+ message: "Research completed: #{query.truncate(50)}"
75
+ }
76
+ )
77
+ end
78
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Creates per-<%= user_variable_name %> MCP clients with OAuth authentication
4
+ class McpClient
5
+ class NotAuthenticatedError < StandardError; end
6
+
7
+ # Create MCP client for a specific <%= user_variable_name %>
8
+ # @param <%= user_variable_name %> [<%= user_model_name %>] the <%= user_variable_name %> to create client for
9
+ # @param server_url [String] MCP server URL (required)
10
+ # @param scope [String] OAuth scopes to request
11
+ # @return [RubyLLM::MCP::Client] configured MCP client
12
+ # @raise [NotAuthenticatedError] if <%= user_variable_name %> hasn't connected to MCP server
13
+ # @raise [ArgumentError] if server_url not provided
14
+ def self.for(<%= user_variable_name %>, server_url:, scope: nil)
15
+ raise ArgumentError, "server_url is required" unless server_url.present?
16
+ scope ||= "mcp:read mcp:write"
17
+
18
+ unless <%= user_variable_name %>.mcp_connected?(server_url)
19
+ raise NotAuthenticatedError,
20
+ "<%= user_model_name %> #{<%= user_variable_name %>.id} has not connected to MCP server: #{server_url}. " \
21
+ "Please complete OAuth flow first."
22
+ end
23
+
24
+ storage = <%= user_variable_name %>.mcp_token_storage(server_url)
25
+
26
+ RubyLLM::MCP.client(
27
+ name: "<%= user_variable_name %>-#{<%= user_variable_name %>.id}-#{server_url.hash.abs}",
28
+ transport_type: determine_transport_type(server_url),
29
+ config: {
30
+ url: server_url,
31
+ oauth: {
32
+ storage: storage,
33
+ scope: scope
34
+ }
35
+ }
36
+ )
37
+ end
38
+
39
+ # Create MCP client for <%= user_variable_name %>, returning nil if not authenticated
40
+ # @param <%= user_variable_name %> [<%= user_model_name %>] the <%= user_variable_name %>
41
+ # @param server_url [String] MCP server URL (required)
42
+ # @return [RubyLLM::MCP::Client, nil] client or nil
43
+ def self.for_with_fallback(<%= user_variable_name %>, server_url:)
44
+ self.for(<%= user_variable_name %>, server_url: server_url)
45
+ rescue NotAuthenticatedError, ArgumentError
46
+ nil
47
+ end
48
+
49
+ # Check if <%= user_variable_name %> has valid MCP connection
50
+ # @param <%= user_variable_name %> [<%= user_model_name %>] the <%= user_variable_name %>
51
+ # @param server_url [String] MCP server URL (required)
52
+ # @return [Boolean] true if <%= user_variable_name %> has valid token
53
+ def self.connected?(<%= user_variable_name %>, server_url:)
54
+ return false unless server_url.present?
55
+
56
+ credential = <%= user_variable_name %>.mcp_oauth_credentials.find_by(server_url: server_url)
57
+ credential&.valid_token? || false
58
+ end
59
+
60
+ # Determine transport type from URL
61
+ # @param url [String] server URL
62
+ # @return [Symbol] :sse or :streamable
63
+ def self.determine_transport_type(url)
64
+ url.include?("/sse") ? :sse : :streamable
65
+ end
66
+
67
+ private_class_method :determine_transport_type
68
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateMcpOauthCredentials < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
+ def change
5
+ create_table :<%= credential_table_name %> do |t|
6
+ t.references :<%= user_table_name.singularize %>, null: false, foreign_key: true, index: true
7
+ t.string :name, null: false
8
+ t.string :server_url, null: false
9
+ t.text :token_data
10
+ t.text :client_info_data
11
+ t.datetime :token_expires_at
12
+ t.datetime :last_refreshed_at
13
+
14
+ t.timestamps
15
+
16
+ t.index [:<%= user_table_name.singularize %>_id, :server_url], unique: true, name: "index_mcp_oauth_on_<%= user_table_name.singularize %>_and_server"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateMcpOauthStates < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
+ def change
5
+ create_table :<%= state_table_name %> do |t|
6
+ t.references :<%= user_table_name.singularize %>, null: false, foreign_key: true
7
+ t.string :server_url, null: false
8
+ t.string :state_param, null: false
9
+ t.text :pkce_data, null: false
10
+ t.datetime :expires_at, null: false
11
+ t.string :name
12
+ t.string :scope
13
+
14
+ t.timestamps
15
+
16
+ t.index [:<%= user_table_name.singularize %>_id, :state_param], unique: true, name: "index_mcp_oauth_states_on_<%= user_table_name.singularize %>_and_state"
17
+ t.index [:<%= user_table_name.singularize %>_id, :server_url], unique: true, name: "index_mcp_oauth_states_on_<%= user_table_name.singularize %>_and_server"
18
+ t.index :expires_at, name: "index_mcp_oauth_states_on_expires_at"
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= credential_model_name %> < ApplicationRecord
4
+ belongs_to :<%= user_table_name.singularize %>
5
+
6
+ encrypts :token_data
7
+ encrypts :client_info_data
8
+
9
+ validates :name, presence: true,
10
+ uniqueness: { scope: :<%= user_table_name.singularize %>_id }
11
+ validates :server_url, presence: true,
12
+ uniqueness: { scope: :<%= user_table_name.singularize %>_id },
13
+ format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]) }
14
+
15
+ # Get token object from stored data
16
+ def token
17
+ return nil unless token_data.present?
18
+
19
+ RubyLLM::MCP::Auth::Token.from_h(JSON.parse(token_data, symbolize_names: true))
20
+ end
21
+
22
+ # Set token and update expiration
23
+ def token=(token_obj)
24
+ self.token_data = token_obj.to_h.to_json
25
+ self.token_expires_at = token_obj.expires_at
26
+ end
27
+
28
+ # Get client info object from stored data
29
+ def client_info
30
+ return nil unless client_info_data.present?
31
+
32
+ RubyLLM::MCP::Auth::ClientInfo.from_h(JSON.parse(client_info_data, symbolize_names: true))
33
+ end
34
+
35
+ # Set client info
36
+ def client_info=(info_obj)
37
+ self.client_info_data = info_obj.to_h.to_json
38
+ end
39
+
40
+ # Check if token is expired
41
+ def expired?
42
+ token&.expired?
43
+ end
44
+
45
+ # Check if token expires soon (within 5 minutes)
46
+ def expires_soon?
47
+ token&.expires_soon?
48
+ end
49
+
50
+ # Check if token is valid
51
+ def valid_token?
52
+ token && !token.expired?
53
+ end
54
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= state_model_name %> < ApplicationRecord
4
+ belongs_to :<%= user_table_name.singularize %>
5
+
6
+ encrypts :pkce_data
7
+
8
+ validates :state_param, presence: true
9
+ validates :expires_at, presence: true
10
+
11
+ scope :expired, -> { where("expires_at < ?", Time.current) }
12
+ scope :for_<%= user_table_name.singularize %>, ->(<%= user_table_name.singularize %>_id) { where(<%= user_table_name.singularize %>_id: <%= user_table_name.singularize %>_id) }
13
+
14
+ # Clean up expired OAuth flow states
15
+ def self.cleanup_expired
16
+ expired.delete_all
17
+ end
18
+
19
+ # Get PKCE object from stored data
20
+ def pkce
21
+ return nil unless pkce_data.present?
22
+
23
+ RubyLLM::MCP::Auth::PKCE.from_h(JSON.parse(pkce_data, symbolize_names: true))
24
+ end
25
+
26
+ # Set PKCE object
27
+ def pkce=(pkce_obj)
28
+ self.pkce_data = pkce_obj.to_h.to_json
29
+ end
30
+ end