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
|
@@ -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
|
data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_credentials.rb.tt
ADDED
|
@@ -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
|