Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ gem 'stimulus-rails'
gem 'turbo-rails'

# TODO: figure out why these need to be here for the specs to run
gem 'devise'
gem 'doorkeeper'
gem 'mcp'

Expand Down
16 changes: 15 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
rolemodel-rails (1.1.0)
rolemodel-rails (1.1.1)
rails (> 7.1)

GEM
Expand Down Expand Up @@ -85,6 +85,7 @@ GEM
public_suffix (>= 2.0.2, < 8.0)
ast (2.4.3)
base64 (0.3.0)
bcrypt (3.1.22)
benchmark (0.5.0)
bigdecimal (4.0.1)
builder (3.3.0)
Expand All @@ -95,6 +96,12 @@ GEM
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
devise (5.0.3)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 7.0)
responders
warden (~> 1.2.3)
diff-lcs (1.6.2)
doorkeeper (5.9.0)
railties (>= 5)
Expand Down Expand Up @@ -149,6 +156,7 @@ GEM
racc (~> 1.4)
nokogiri (1.19.0-x86_64-linux-gnu)
racc (~> 1.4)
orm_adapter (0.5.0)
parallel (1.27.0)
parser (3.3.8.0)
ast (~> 2.4.1)
Expand Down Expand Up @@ -217,6 +225,9 @@ GEM
regexp_parser (2.10.0)
reline (0.6.3)
io-console (~> 0.5)
responders (3.2.0)
actionpack (>= 7.0)
railties (>= 7.0)
rspec (3.13.2)
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
Expand Down Expand Up @@ -269,6 +280,8 @@ GEM
unicode-emoji (4.2.0)
uri (1.1.1)
useragent (0.16.11)
warden (1.2.9)
rack (>= 2.0.9)
websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
Expand All @@ -284,6 +297,7 @@ DEPENDENCIES
benchmark
bundler (~> 4)
debug (>= 1.0.0)
devise
doorkeeper
generator_spec (~> 0.10)
mcp
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ bin/rails g
* [Editors](./lib/generators/rolemodel/editors)
* [Tailored Select](./lib/generators/rolemodel/tailored_select)
* [Lograge](./lib/generators/rolemodel/lograge)
* [MCP](./lib/generators/rolemodel/mcp)

## Development

Expand Down
6 changes: 3 additions & 3 deletions example_rails_current/config/environments/test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@
# this is usually not necessary, and can slow down your test suite. However, it's
# recommended that you enable it in continuous integration systems to ensure eager
# loading is working properly before deploying your code.
config.eager_load = ENV["CI"].present?
config.eager_load = false # This messes with the MCP generator spec.

# Configure public file server for tests with cache-control for performance.
config.public_file_server.headers = { "cache-control" => "public, max-age=3600" }
config.public_file_server.headers = { 'cache-control' => 'public, max-age=3600' }

# Show full error reports.
config.consider_all_requests_local = true
Expand All @@ -37,7 +37,7 @@
config.action_mailer.delivery_method = :test

# Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "example.com" }
config.action_mailer.default_url_options = { host: 'example.com' }

# Print deprecation notices to the stderr.
config.active_support.deprecation = :stderr
Expand Down
2 changes: 1 addition & 1 deletion example_rails_legacy/Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: ..
specs:
rolemodel-rails (1.1.0)
rolemodel-rails (1.1.1)
rails (> 7.1)

GEM
Expand Down
2 changes: 1 addition & 1 deletion example_rails_legacy/config/environments/test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
# this is usually not necessary, and can slow down your test suite. However, it's
# recommended that you enable it in continuous integration systems to ensure eager
# loading is working properly before deploying your code.
config.eager_load = ENV["CI"].present?
config.eager_load = ENV["CI"].present? # This messes with the MCP generator spec.

# Configure public file server for tests with Cache-Control for performance.
config.public_file_server.headers = { "Cache-Control" => "public, max-age=#{1.hour.to_i}" }
Expand Down
7 changes: 7 additions & 0 deletions lib/generators/rolemodel/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,10 @@ OAuth 2.1-enabled flow with dynamic application registration.
### MCP

A basic MCP controller that you can build on to serve tools, resources, and prompts.

## Options
### Devise
If you use devise, respond "yes" and authorization will be ignored when relevant

### Policy library
If you use Pundit or ActionPolicy, a policy will be installed and soe error handling added.
53 changes: 42 additions & 11 deletions lib/generators/rolemodel/mcp/mcp_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,23 @@ module Rolemodel
class McpGenerator < GeneratorBase
source_root File.expand_path('templates', __dir__)

def update_inflections
def ask_policy_choice
@chosen_policy_library = ask(
'What authorization library do you use?',
default: 'action_policy',
limited_to: %w[pundit action_policy]
)
end

def ask_devise
@uses_devise = ask(
'Do you use Devise for authentication?',
default: 'yes',
limited_to: %w[yes no]
) == 'yes'
end

def update_inflections # rubocop:disable Metrics/MethodLength
inflections_path = File.join(destination_root, 'config/initializers/inflections.rb')
block_start = "\nActiveSupport::Inflector.inflections(:en) do |inflect|\n"

Expand All @@ -26,19 +42,21 @@ def install_mcp
bundle_command 'add mcp'
template 'app/controllers/mcp_controller.rb'
copy_file 'spec/requests/mcp_controller_spec.rb'
copy_file 'app/policies/mcp_policy.rb'
template 'spec/policies/mcp_policy_spec.rb'

route <<~RUBY
match '/mcp', to: 'mcp#handle', via: %i[get post delete]
RUBY
end

def add_sample_mcp_resource
copy_file 'app/mcp/resources/controller.rb'
copy_file 'spec/mcp/resources/controller_spec.rb'
copy_file 'app/mcp/resources/handler.rb'
copy_file 'spec/mcp/resources/handler_spec.rb'

copy_file 'app/mcp/resources/docs/SAMPLE_DOC.md'
copy_file 'app/mcp/resources/docs_controller.rb'
copy_file 'spec/mcp/resources/docs_controller_spec.rb'
copy_file 'app/mcp/resources/docs_handler.rb'
copy_file 'spec/mcp/resources/docs_handler_spec.rb'
end

def add_sample_mcp_prompt
Expand All @@ -55,6 +73,7 @@ def install_doorkeeper
bundle_command 'add doorkeeper'
run_bundle
generate 'doorkeeper:install'
generate 'doorkeeper:migration'
end

def configure_doorkeeper
Expand All @@ -72,10 +91,10 @@ def configure_doorkeeper

def apply_doorkeeper_css
css_manifest = if File.exist?(File.join(destination_root, 'app/assets/stylesheets/application.scss'))
'app/assets/stylesheets/application.scss'
else
'app/assets/stylesheets/application.css'
end
'app/assets/stylesheets/application.scss'
else
'app/assets/stylesheets/application.css'
end

return if File.read(File.join(destination_root, css_manifest)).include?("@import 'components/doorkeeper.css';")

Expand All @@ -85,15 +104,15 @@ def apply_doorkeeper_css
end

def add_oauth_dynamic_registrations
copy_file 'app/controllers/oauth_registrations_controller.rb'
template 'app/controllers/oauth_registrations_controller.rb'
copy_file 'spec/requests/oauth_registrations_controller_spec.rb'
route <<~RUBY
post '/oauth/register', to: 'oauth_registrations#create'
RUBY
end

def add_well_known_route
copy_file 'app/controllers/well_known_controller.rb'
template 'app/controllers/well_known_controller.rb'
copy_file 'spec/requests/well_known_controller_spec.rb'
route <<~RUBY
get '/.well-known/oauth-protected-resource', to: 'well_known#oauth_protected_resource'
Expand All @@ -106,5 +125,17 @@ def add_well_known_route
def application_name
Rails.application.name
end

def pundit?
@chosen_policy_library == 'pundit'
end

def action_policy?
@chosen_policy_library == 'action_policy'
end

def devise?
@uses_devise
end
end
end
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
# frozen_string_literal: true

class MCPController < ApplicationController
# skip_before_action :authenticate_user!
<%- if devise? %>skip_before_action :authenticate_user!<% end %>
skip_forgery_protection

before_action :authorize_mcp
before_action :set_current_user

<%- if action_policy? %>
rescue_from ActionPolicy::Unauthorized, with: :unauthorized_request
<%- elsif pundit? %>
rescue_from Pundit::NotAuthorizedError, with: :unauthorized_request
<% end %>

def handle
authorize!
Comment thread
justwiebe marked this conversation as resolved.
server = build_server
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
server.transport = transport
Expand Down Expand Up @@ -52,23 +59,23 @@ class MCPController < ApplicationController
end

def handle_resources(server) # rubocop:disable Metrics/MethodLength
controllers = [
Resources::DocsController
handlers = [
Resources::DocsHandler
]

server.resources_read_handler do |params|
uri = params[:uri].to_s
controller = controllers.find { |h| h.serves?(uri) }
handler = handlers.find { |h| h.serves?(uri) }

unless controller
unless handler
raise MCP::Server::RequestHandlerError.new(
"Unable to serve resource for URI: #{uri}. Supported schemas: #{controllers.map(&:schema).join(', ')}",
"Unable to serve resource for URI: #{uri}. Supported schemas: #{handlers.map(&:schema).join(', ')}",
params,
error_type: :invalid_params
)
end

controller.call(params, server_context)
handler.call(params, server_context)
end
end

Expand All @@ -80,7 +87,7 @@ class MCPController < ApplicationController
prompts: [Prompts::Sample],
server_context:,
resources: [
*Resources::DocsController.resource_list,
*Resources::DocsHandler.resource_list,
],
}
end
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# frozen_string_literal: true

class OauthRegistrationsController < ApplicationController
# skip_before_action :authenticate_user!
<%- if devise? %>skip_before_action :authenticate_user!<% end %>
<%- if action_policy? %>skip_verify_authorized<% end %>
<%- if pundit? %>skip_after_action :verify_authorized<% end %>
skip_forgery_protection

def create
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# frozen_string_literal: true

class WellKnownController < ApplicationController
# skip_before_action :authenticate_user!
<%- if devise? %>skip_before_action :authenticate_user!<% end %>
<%- if action_policy? %>skip_verify_authorized<% end %>
<%- if pundit? %>skip_after_action :verify_authorized<% end %>
before_action :set_base_url

def oauth_protected_resource
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
# frozen_string_literal: true

module Resources
class DocsController < Controller
class DocsHandler < Handler
FILES = {
'SAMPLE_DOC.md' => Rails.root.join('app/mcp/resources/docs/blazer-documentation.md'),
'SAMPLE_DOC.md' => Rails.root.join('app/mcp/resources/docs/SAMPLE_DOC.md')
}.freeze

mime_type 'text/markdown'
schema 'docs://'

attribute :file_path

validates :file_path, presence: { message: ->(controller, _) { "Unknown docs resource: #{controller.path}" } }
validates :file_path, presence: { message: ->(handler, _) { "Unknown docs resource: #{handler.path}" } }
validate :file_exists

def initialize(path, _server_context = nil)
Expand All @@ -26,8 +26,8 @@ def self.resource_list
name: 'sample_doc',
title: 'Sample Resource',
description: 'Sample resource',
mime_type: mime_type,
),
mime_type: mime_type
)
]
end

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# frozen_string_literal: true

module Resources
class Controller
class Handler
include ActiveModel::Attributes
include ActiveModel::API

attribute :server_context
attribute :path, :string

validates :path, presence: { message: 'is required' } # rubocop:disable Rails/I18nLocaleTexts
validates :path, presence: { message: 'is required' }

class << self
def mime_type(mime_type = nil)
Expand All @@ -26,17 +26,17 @@ def serves?(uri)
end

def call(params, server_context)
controller = new(params[:uri].sub(schema, ''), server_context)
handler = new(params[:uri].sub(schema, ''), server_context)

unless controller.valid?
unless handler.valid?
raise ::MCP::Server::RequestHandlerError.new(
controller.errors.full_messages.join(', '),
handler.errors.full_messages.join(', '),
params,
error_type: :invalid_params
)
end

[{ uri: params[:uri], mimeType: mime_type, text: controller.serve }]
[{ uri: params[:uri], mimeType: mime_type, text: handler.serve }]
end
end

Expand Down
Loading