From b31bcde04a5d6ef13894670e18f351fe2f67e449 Mon Sep 17 00:00:00 2001 From: Justin Wiebe Date: Tue, 7 Apr 2026 11:38:56 -0500 Subject: [PATCH 1/6] CMP-354 | Add some fixes to make the MCP generator more complete --- lib/generators/rolemodel/mcp/mcp_generator.rb | 3 +++ .../app/controllers/mcp_controller.rb.tt | 3 +++ .../oauth_registrations_controller.rb | 1 + .../app/controllers/well_known_controller.rb | 1 + .../app/mcp/resources/docs_controller.rb | 2 +- .../mcp/templates/app/policies/mcp_policy.rb | 7 +++++++ .../templates/spec/mcp/prompts/sample_spec.rb | 2 +- .../spec/mcp/resources/controller_spec.rb | 1 + .../templates/spec/policies/mcp_policy_spec.rb | 17 +++++++++++++++++ spec/generators/rolemodel/mcp_generator_spec.rb | 3 +++ 10 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 lib/generators/rolemodel/mcp/templates/app/policies/mcp_policy.rb create mode 100644 lib/generators/rolemodel/mcp/templates/spec/policies/mcp_policy_spec.rb diff --git a/lib/generators/rolemodel/mcp/mcp_generator.rb b/lib/generators/rolemodel/mcp/mcp_generator.rb index 5b063e74..f8f15365 100644 --- a/lib/generators/rolemodel/mcp/mcp_generator.rb +++ b/lib/generators/rolemodel/mcp/mcp_generator.rb @@ -26,6 +26,8 @@ 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' + copy_file 'spec/policies/mcp_policy_spec.rb' route <<~RUBY match '/mcp', to: 'mcp#handle', via: %i[get post delete] @@ -55,6 +57,7 @@ def install_doorkeeper bundle_command 'add doorkeeper' run_bundle generate 'doorkeeper:install' + generate 'doorkeeper:migration' end def configure_doorkeeper diff --git a/lib/generators/rolemodel/mcp/templates/app/controllers/mcp_controller.rb.tt b/lib/generators/rolemodel/mcp/templates/app/controllers/mcp_controller.rb.tt index b2606e66..af0de575 100644 --- a/lib/generators/rolemodel/mcp/templates/app/controllers/mcp_controller.rb.tt +++ b/lib/generators/rolemodel/mcp/templates/app/controllers/mcp_controller.rb.tt @@ -7,7 +7,10 @@ class MCPController < ApplicationController before_action :authorize_mcp before_action :set_current_user + rescue_from ActionPolicy::Unauthorized, with: :unauthorized_request + def handle + authorize! server = build_server transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true) server.transport = transport diff --git a/lib/generators/rolemodel/mcp/templates/app/controllers/oauth_registrations_controller.rb b/lib/generators/rolemodel/mcp/templates/app/controllers/oauth_registrations_controller.rb index 6b548327..a18b0d0d 100644 --- a/lib/generators/rolemodel/mcp/templates/app/controllers/oauth_registrations_controller.rb +++ b/lib/generators/rolemodel/mcp/templates/app/controllers/oauth_registrations_controller.rb @@ -2,6 +2,7 @@ class OauthRegistrationsController < ApplicationController # skip_before_action :authenticate_user! + # skip_verify_authorized skip_forgery_protection def create diff --git a/lib/generators/rolemodel/mcp/templates/app/controllers/well_known_controller.rb b/lib/generators/rolemodel/mcp/templates/app/controllers/well_known_controller.rb index 53063bf7..fe309706 100644 --- a/lib/generators/rolemodel/mcp/templates/app/controllers/well_known_controller.rb +++ b/lib/generators/rolemodel/mcp/templates/app/controllers/well_known_controller.rb @@ -2,6 +2,7 @@ class WellKnownController < ApplicationController # skip_before_action :authenticate_user! + # skip_verify_authorized before_action :set_base_url def oauth_protected_resource diff --git a/lib/generators/rolemodel/mcp/templates/app/mcp/resources/docs_controller.rb b/lib/generators/rolemodel/mcp/templates/app/mcp/resources/docs_controller.rb index 617ff3da..3353cf62 100644 --- a/lib/generators/rolemodel/mcp/templates/app/mcp/resources/docs_controller.rb +++ b/lib/generators/rolemodel/mcp/templates/app/mcp/resources/docs_controller.rb @@ -3,7 +3,7 @@ module Resources class DocsController < Controller 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' diff --git a/lib/generators/rolemodel/mcp/templates/app/policies/mcp_policy.rb b/lib/generators/rolemodel/mcp/templates/app/policies/mcp_policy.rb new file mode 100644 index 00000000..6debcb89 --- /dev/null +++ b/lib/generators/rolemodel/mcp/templates/app/policies/mcp_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class MCPPolicy < ApplicationPolicy + def handle? + user.present? + end +end diff --git a/lib/generators/rolemodel/mcp/templates/spec/mcp/prompts/sample_spec.rb b/lib/generators/rolemodel/mcp/templates/spec/mcp/prompts/sample_spec.rb index 6c9e1e9f..2cb186c3 100644 --- a/lib/generators/rolemodel/mcp/templates/spec/mcp/prompts/sample_spec.rb +++ b/lib/generators/rolemodel/mcp/templates/spec/mcp/prompts/sample_spec.rb @@ -9,7 +9,7 @@ it 'has the correct description' do expect(result.messages.length).to eq(1) expect(result.messages.first.role).to eq('assistant') - expect(result.description).to include('This is a sample prompt.') + expect(result.description).to include('Sample prompt result description.') end end end diff --git a/lib/generators/rolemodel/mcp/templates/spec/mcp/resources/controller_spec.rb b/lib/generators/rolemodel/mcp/templates/spec/mcp/resources/controller_spec.rb index c530958d..3a67a31d 100644 --- a/lib/generators/rolemodel/mcp/templates/spec/mcp/resources/controller_spec.rb +++ b/lib/generators/rolemodel/mcp/templates/spec/mcp/resources/controller_spec.rb @@ -4,6 +4,7 @@ RSpec.describe Resources::Controller do it 'returns error messages when invalid' do + allow(described_class).to receive(:schema).and_return('docs://') allow_any_instance_of(described_class).to receive(:valid?).and_return(false) allow_any_instance_of(described_class).to receive_message_chain(:errors, :full_messages).and_return(['Invalid params']) expect do diff --git a/lib/generators/rolemodel/mcp/templates/spec/policies/mcp_policy_spec.rb b/lib/generators/rolemodel/mcp/templates/spec/policies/mcp_policy_spec.rb new file mode 100644 index 00000000..e25eef9b --- /dev/null +++ b/lib/generators/rolemodel/mcp/templates/spec/policies/mcp_policy_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe MCPPolicy do + let(:user) { create :client } + let(:record) { :mcp } + let(:context) { { user: user } } + + describe_rule :handle? do + succeed 'when logged in' + + failed 'when anonymous' do + let(:user) { nil } + end + end +end diff --git a/spec/generators/rolemodel/mcp_generator_spec.rb b/spec/generators/rolemodel/mcp_generator_spec.rb index a24dd6ff..fbb993a9 100644 --- a/spec/generators/rolemodel/mcp_generator_spec.rb +++ b/spec/generators/rolemodel/mcp_generator_spec.rb @@ -8,6 +8,9 @@ end assert_file 'spec/requests/mcp_controller_spec.rb' + assert_file 'app/policies/mcp_policy.rb' + assert_file 'spec/policies/mcp_policy_spec.rb' + assert_file 'config/routes.rb' do |content| expect(content).to include("match '/mcp', to: 'mcp#handle', via: %i[get post delete]") end From b77b984dcd1c4f78f263da41cc8f986c2166a221 Mon Sep 17 00:00:00 2001 From: Justin Wiebe Date: Tue, 7 Apr 2026 11:40:16 -0500 Subject: [PATCH 2/6] Bump version --- Gemfile.lock | 2 +- example_rails_legacy/Gemfile.lock | 2 +- lib/rolemodel/version.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index e235a589..2366c0f1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - rolemodel-rails (1.1.0) + rolemodel-rails (1.1.1) rails (> 7.1) GEM diff --git a/example_rails_legacy/Gemfile.lock b/example_rails_legacy/Gemfile.lock index bca04a45..093d721c 100644 --- a/example_rails_legacy/Gemfile.lock +++ b/example_rails_legacy/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: .. specs: - rolemodel-rails (1.1.0) + rolemodel-rails (1.1.1) rails (> 7.1) GEM diff --git a/lib/rolemodel/version.rb b/lib/rolemodel/version.rb index 1a8d23a7..b50b147c 100644 --- a/lib/rolemodel/version.rb +++ b/lib/rolemodel/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Rolemodel - VERSION = '1.1.0' + VERSION = '1.1.1' end From bbeec3b921234a1fa2011b239bdcd78f8c17291d Mon Sep 17 00:00:00 2001 From: Justin Wiebe Date: Tue, 21 Apr 2026 15:42:13 -0400 Subject: [PATCH 3/6] Rename controller to handler --- lib/generators/rolemodel/mcp/mcp_generator.rb | 16 ++++++------- .../app/controllers/mcp_controller.rb.tt | 14 +++++------ .../{docs_controller.rb => docs_handler.rb} | 10 ++++---- .../resources/{controller.rb => handler.rb} | 12 +++++----- ...ontroller_spec.rb => docs_handler_spec.rb} | 24 +++++++++---------- .../{controller_spec.rb => handler_spec.rb} | 5 ++-- .../rolemodel/mcp_generator_spec.rb | 8 +++---- 7 files changed, 45 insertions(+), 44 deletions(-) rename lib/generators/rolemodel/mcp/templates/app/mcp/resources/{docs_controller.rb => docs_handler.rb} (78%) rename lib/generators/rolemodel/mcp/templates/app/mcp/resources/{controller.rb => handler.rb} (72%) rename lib/generators/rolemodel/mcp/templates/spec/mcp/resources/{docs_controller_spec.rb => docs_handler_spec.rb} (59%) rename lib/generators/rolemodel/mcp/templates/spec/mcp/resources/{controller_spec.rb => handler_spec.rb} (75%) diff --git a/lib/generators/rolemodel/mcp/mcp_generator.rb b/lib/generators/rolemodel/mcp/mcp_generator.rb index f8f15365..b5f31b34 100644 --- a/lib/generators/rolemodel/mcp/mcp_generator.rb +++ b/lib/generators/rolemodel/mcp/mcp_generator.rb @@ -35,12 +35,12 @@ def install_mcp 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 @@ -75,10 +75,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';") diff --git a/lib/generators/rolemodel/mcp/templates/app/controllers/mcp_controller.rb.tt b/lib/generators/rolemodel/mcp/templates/app/controllers/mcp_controller.rb.tt index af0de575..4821950a 100644 --- a/lib/generators/rolemodel/mcp/templates/app/controllers/mcp_controller.rb.tt +++ b/lib/generators/rolemodel/mcp/templates/app/controllers/mcp_controller.rb.tt @@ -55,23 +55,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 @@ -83,7 +83,7 @@ class MCPController < ApplicationController prompts: [Prompts::Sample], server_context:, resources: [ - *Resources::DocsController.resource_list, + *Resources::DocsHandler.resource_list, ], } end diff --git a/lib/generators/rolemodel/mcp/templates/app/mcp/resources/docs_controller.rb b/lib/generators/rolemodel/mcp/templates/app/mcp/resources/docs_handler.rb similarity index 78% rename from lib/generators/rolemodel/mcp/templates/app/mcp/resources/docs_controller.rb rename to lib/generators/rolemodel/mcp/templates/app/mcp/resources/docs_handler.rb index 3353cf62..1f9bb362 100644 --- a/lib/generators/rolemodel/mcp/templates/app/mcp/resources/docs_controller.rb +++ b/lib/generators/rolemodel/mcp/templates/app/mcp/resources/docs_handler.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true module Resources - class DocsController < Controller + class DocsHandler < Handler FILES = { - 'SAMPLE_DOC.md' => Rails.root.join('app/mcp/resources/docs/SAMPLE_DOC.md'), + 'SAMPLE_DOC.md' => Rails.root.join('app/mcp/resources/docs/SAMPLE_DOC.md') }.freeze mime_type 'text/markdown' @@ -11,7 +11,7 @@ class DocsController < Controller 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) @@ -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 diff --git a/lib/generators/rolemodel/mcp/templates/app/mcp/resources/controller.rb b/lib/generators/rolemodel/mcp/templates/app/mcp/resources/handler.rb similarity index 72% rename from lib/generators/rolemodel/mcp/templates/app/mcp/resources/controller.rb rename to lib/generators/rolemodel/mcp/templates/app/mcp/resources/handler.rb index 2bde96c8..671d9b4d 100644 --- a/lib/generators/rolemodel/mcp/templates/app/mcp/resources/controller.rb +++ b/lib/generators/rolemodel/mcp/templates/app/mcp/resources/handler.rb @@ -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) @@ -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 diff --git a/lib/generators/rolemodel/mcp/templates/spec/mcp/resources/docs_controller_spec.rb b/lib/generators/rolemodel/mcp/templates/spec/mcp/resources/docs_handler_spec.rb similarity index 59% rename from lib/generators/rolemodel/mcp/templates/spec/mcp/resources/docs_controller_spec.rb rename to lib/generators/rolemodel/mcp/templates/spec/mcp/resources/docs_handler_spec.rb index 887406ed..763b18aa 100644 --- a/lib/generators/rolemodel/mcp/templates/spec/mcp/resources/docs_controller_spec.rb +++ b/lib/generators/rolemodel/mcp/templates/spec/mcp/resources/docs_handler_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe Resources::DocsController do +RSpec.describe Resources::DocsHandler do describe 'class methods' do it 'returns the correct values' do expect(described_class.schema).to eq('docs://') @@ -18,36 +18,36 @@ describe 'validations' do it 'is valid for a known docs resource' do - controller = described_class.new('SAMPLE_DOC.md') + handler = described_class.new('SAMPLE_DOC.md') - expect(controller).to be_valid + expect(handler).to be_valid end it 'is invalid for an unknown docs resource' do - controller = described_class.new('missing-doc') + handler = described_class.new('missing-doc') - expect(controller).not_to be_valid - expect(controller.errors[:file_path]).to eq(['Unknown docs resource: missing-doc']) + expect(handler).not_to be_valid + expect(handler.errors[:file_path]).to eq(['Unknown docs resource: missing-doc']) end it 'is invalid when the mapped file is missing' do stub_const( - 'Resources::DocsController::FILES', + 'Resources::DocsHandler::FILES', { 'SAMPLE_DOC.md' => Rails.root.join('app/mcp/resources/docs/missing.md') }.freeze ) - controller = described_class.new('SAMPLE_DOC.md') + handler = described_class.new('SAMPLE_DOC.md') - expect(controller).not_to be_valid - expect(controller.errors[:file_path]).to eq(['Missing docs file for SAMPLE_DOC.md']) + expect(handler).not_to be_valid + expect(handler.errors[:file_path]).to eq(['Missing docs file for SAMPLE_DOC.md']) end end describe '#serve' do it 'returns the markdown for the requested docs resource' do - controller = described_class.new('SAMPLE_DOC.md') + handler = described_class.new('SAMPLE_DOC.md') - content = controller.serve + content = handler.serve expect(content).to include('# Hello World') end diff --git a/lib/generators/rolemodel/mcp/templates/spec/mcp/resources/controller_spec.rb b/lib/generators/rolemodel/mcp/templates/spec/mcp/resources/handler_spec.rb similarity index 75% rename from lib/generators/rolemodel/mcp/templates/spec/mcp/resources/controller_spec.rb rename to lib/generators/rolemodel/mcp/templates/spec/mcp/resources/handler_spec.rb index 3a67a31d..d401bddc 100644 --- a/lib/generators/rolemodel/mcp/templates/spec/mcp/resources/controller_spec.rb +++ b/lib/generators/rolemodel/mcp/templates/spec/mcp/resources/handler_spec.rb @@ -2,11 +2,12 @@ require 'rails_helper' -RSpec.describe Resources::Controller do +RSpec.describe Resources::Handler do it 'returns error messages when invalid' do allow(described_class).to receive(:schema).and_return('docs://') allow_any_instance_of(described_class).to receive(:valid?).and_return(false) - allow_any_instance_of(described_class).to receive_message_chain(:errors, :full_messages).and_return(['Invalid params']) + allow_any_instance_of(described_class).to receive_message_chain(:errors, + :full_messages).and_return(['Invalid params']) expect do described_class.call({ uri: 'docs://missing-doc' }, { current_user: nil }) end.to raise_error( diff --git a/spec/generators/rolemodel/mcp_generator_spec.rb b/spec/generators/rolemodel/mcp_generator_spec.rb index fbb993a9..002c832c 100644 --- a/spec/generators/rolemodel/mcp_generator_spec.rb +++ b/spec/generators/rolemodel/mcp_generator_spec.rb @@ -21,10 +21,10 @@ it 'adds sample MCP resource, tool, and prompt' do assert_file 'app/mcp/resources/docs/SAMPLE_DOC.md' - assert_file 'app/mcp/resources/controller.rb' - assert_file 'spec/mcp/resources/controller_spec.rb' - assert_file 'app/mcp/resources/docs_controller.rb' - assert_file 'spec/mcp/resources/docs_controller_spec.rb' + assert_file 'app/mcp/resources/handler.rb' + assert_file 'spec/mcp/resources/handler_spec.rb' + assert_file 'app/mcp/resources/docs_handler.rb' + assert_file 'spec/mcp/resources/docs_handler_spec.rb' assert_file 'app/mcp/prompts/sample.rb' assert_file 'spec/mcp/prompts/sample_spec.rb' assert_file 'app/mcp/tools/sample.rb' From 49c4ae3c15a7a1c1c962ce8e3887abb3c3857bca Mon Sep 17 00:00:00 2001 From: Justin Wiebe Date: Tue, 21 Apr 2026 16:31:43 -0400 Subject: [PATCH 4/6] Add devise and policy options --- lib/generators/rolemodel/mcp/README.md | 7 ++ lib/generators/rolemodel/mcp/mcp_generator.rb | 36 +++++++- .../app/controllers/mcp_controller.rb.tt | 6 +- ...b => oauth_registrations_controller.rb.tt} | 5 +- ...troller.rb => well_known_controller.rb.tt} | 5 +- .../templates/spec/mcp/prompts/sample_spec.rb | 2 +- .../spec/policies/mcp_policy_spec.rb | 17 ---- .../spec/policies/mcp_policy_spec.rb.tt | 31 +++++++ .../rolemodel/mcp_generator_spec.rb | 91 ++++++++++++++++--- 9 files changed, 158 insertions(+), 42 deletions(-) rename lib/generators/rolemodel/mcp/templates/app/controllers/{oauth_registrations_controller.rb => oauth_registrations_controller.rb.tt} (87%) rename lib/generators/rolemodel/mcp/templates/app/controllers/{well_known_controller.rb => well_known_controller.rb.tt} (85%) delete mode 100644 lib/generators/rolemodel/mcp/templates/spec/policies/mcp_policy_spec.rb create mode 100644 lib/generators/rolemodel/mcp/templates/spec/policies/mcp_policy_spec.rb.tt diff --git a/lib/generators/rolemodel/mcp/README.md b/lib/generators/rolemodel/mcp/README.md index 4b15f0b9..0d131cab 100644 --- a/lib/generators/rolemodel/mcp/README.md +++ b/lib/generators/rolemodel/mcp/README.md @@ -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. diff --git a/lib/generators/rolemodel/mcp/mcp_generator.rb b/lib/generators/rolemodel/mcp/mcp_generator.rb index b5f31b34..ad187f43 100644 --- a/lib/generators/rolemodel/mcp/mcp_generator.rb +++ b/lib/generators/rolemodel/mcp/mcp_generator.rb @@ -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" @@ -27,7 +43,7 @@ def install_mcp template 'app/controllers/mcp_controller.rb' copy_file 'spec/requests/mcp_controller_spec.rb' copy_file 'app/policies/mcp_policy.rb' - copy_file 'spec/policies/mcp_policy_spec.rb' + template 'spec/policies/mcp_policy_spec.rb' route <<~RUBY match '/mcp', to: 'mcp#handle', via: %i[get post delete] @@ -88,7 +104,7 @@ 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' @@ -96,7 +112,7 @@ def add_oauth_dynamic_registrations 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' @@ -109,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 diff --git a/lib/generators/rolemodel/mcp/templates/app/controllers/mcp_controller.rb.tt b/lib/generators/rolemodel/mcp/templates/app/controllers/mcp_controller.rb.tt index 4821950a..166fca37 100644 --- a/lib/generators/rolemodel/mcp/templates/app/controllers/mcp_controller.rb.tt +++ b/lib/generators/rolemodel/mcp/templates/app/controllers/mcp_controller.rb.tt @@ -1,13 +1,17 @@ # 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! diff --git a/lib/generators/rolemodel/mcp/templates/app/controllers/oauth_registrations_controller.rb b/lib/generators/rolemodel/mcp/templates/app/controllers/oauth_registrations_controller.rb.tt similarity index 87% rename from lib/generators/rolemodel/mcp/templates/app/controllers/oauth_registrations_controller.rb rename to lib/generators/rolemodel/mcp/templates/app/controllers/oauth_registrations_controller.rb.tt index a18b0d0d..1d9b8eaa 100644 --- a/lib/generators/rolemodel/mcp/templates/app/controllers/oauth_registrations_controller.rb +++ b/lib/generators/rolemodel/mcp/templates/app/controllers/oauth_registrations_controller.rb.tt @@ -1,8 +1,9 @@ # frozen_string_literal: true class OauthRegistrationsController < ApplicationController - # skip_before_action :authenticate_user! - # skip_verify_authorized + <%- 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 diff --git a/lib/generators/rolemodel/mcp/templates/app/controllers/well_known_controller.rb b/lib/generators/rolemodel/mcp/templates/app/controllers/well_known_controller.rb.tt similarity index 85% rename from lib/generators/rolemodel/mcp/templates/app/controllers/well_known_controller.rb rename to lib/generators/rolemodel/mcp/templates/app/controllers/well_known_controller.rb.tt index fe309706..72086043 100644 --- a/lib/generators/rolemodel/mcp/templates/app/controllers/well_known_controller.rb +++ b/lib/generators/rolemodel/mcp/templates/app/controllers/well_known_controller.rb.tt @@ -1,8 +1,9 @@ # frozen_string_literal: true class WellKnownController < ApplicationController - # skip_before_action :authenticate_user! - # skip_verify_authorized + <%- 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 diff --git a/lib/generators/rolemodel/mcp/templates/spec/mcp/prompts/sample_spec.rb b/lib/generators/rolemodel/mcp/templates/spec/mcp/prompts/sample_spec.rb index 2cb186c3..b28919c6 100644 --- a/lib/generators/rolemodel/mcp/templates/spec/mcp/prompts/sample_spec.rb +++ b/lib/generators/rolemodel/mcp/templates/spec/mcp/prompts/sample_spec.rb @@ -9,7 +9,7 @@ it 'has the correct description' do expect(result.messages.length).to eq(1) expect(result.messages.first.role).to eq('assistant') - expect(result.description).to include('Sample prompt result description.') + expect(result.description).to include('Sample prompt result description') end end end diff --git a/lib/generators/rolemodel/mcp/templates/spec/policies/mcp_policy_spec.rb b/lib/generators/rolemodel/mcp/templates/spec/policies/mcp_policy_spec.rb deleted file mode 100644 index e25eef9b..00000000 --- a/lib/generators/rolemodel/mcp/templates/spec/policies/mcp_policy_spec.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe MCPPolicy do - let(:user) { create :client } - let(:record) { :mcp } - let(:context) { { user: user } } - - describe_rule :handle? do - succeed 'when logged in' - - failed 'when anonymous' do - let(:user) { nil } - end - end -end diff --git a/lib/generators/rolemodel/mcp/templates/spec/policies/mcp_policy_spec.rb.tt b/lib/generators/rolemodel/mcp/templates/spec/policies/mcp_policy_spec.rb.tt new file mode 100644 index 00000000..f84e6e8b --- /dev/null +++ b/lib/generators/rolemodel/mcp/templates/spec/policies/mcp_policy_spec.rb.tt @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe MCPPolicy do + let(:user) { create :client } + let(:record) { :mcp } + <%- if action_policy? %> + let(:context) { { user: user } } + + describe_rule :handle? do + succeed 'when logged in' + + failed 'when anonymous' do + let(:user) { nil } + end + end + <% elsif pundit? %> + subject { described_class } + + permissions :handle? do + it 'allows access when logged in' do + expect(subject).to permit(user, record) + end + + it 'denies access when anonymous' do + expect(subject).not_to permit(nil, record) + end + end + <% end %> +end diff --git a/spec/generators/rolemodel/mcp_generator_spec.rb b/spec/generators/rolemodel/mcp_generator_spec.rb index 002c832c..1958ab7a 100644 --- a/spec/generators/rolemodel/mcp_generator_spec.rb +++ b/spec/generators/rolemodel/mcp_generator_spec.rb @@ -1,15 +1,29 @@ RSpec.describe Rolemodel::McpGenerator, type: :generator do - before { run_generator_against_test_app } + let(:policy_choice) { 'pundit' } + let(:devise_choice) { 'yes' } - it 'adds the MCP controller' do + before do + respond_to_prompt with: policy_choice + respond_to_prompt with: devise_choice + run_generator_against_test_app + end + + # They're all in a single test because the MCP generator takes so long to run. + it 'adds the MCP controller and all related resources' do # rubocop:disable Metrics/BlockLength assert_file 'app/controllers/mcp_controller.rb' do |content| + expect(content).to include('skip_before_action :authenticate_user!') + expect(content).not_to include('rescue_from ActionPolicy::Unauthorized, with: :unauthorized_request') + expect(content).to include('rescue_from Pundit::NotAuthorizedError, with: :unauthorized_request') expect(content).to include('Example Rails Current') expect(content).to include('example_rails_current') end assert_file 'spec/requests/mcp_controller_spec.rb' assert_file 'app/policies/mcp_policy.rb' - assert_file 'spec/policies/mcp_policy_spec.rb' + assert_file 'spec/policies/mcp_policy_spec.rb' do |content| + expect(content).not_to include('describe_rule :handle?') + expect(content).to include('permissions :handle?') + end assert_file 'config/routes.rb' do |content| expect(content).to include("match '/mcp', to: 'mcp#handle', via: %i[get post delete]") @@ -17,9 +31,8 @@ assert_file 'Gemfile' do |content| expect(content).to include('gem "mcp"') end - end - it 'adds sample MCP resource, tool, and prompt' do + # sample MCP resource, tool, and prompt assert_file 'app/mcp/resources/docs/SAMPLE_DOC.md' assert_file 'app/mcp/resources/handler.rb' assert_file 'spec/mcp/resources/handler_spec.rb' @@ -29,9 +42,8 @@ assert_file 'spec/mcp/prompts/sample_spec.rb' assert_file 'app/mcp/tools/sample.rb' assert_file 'spec/mcp/tools/sample_spec.rb' - end - it 'adds doorkeeper' do + # Doorkeeper assert_file 'Gemfile' do |content| expect(content).to include('gem "doorkeeper"') end @@ -53,31 +65,80 @@ assert_file 'app/assets/stylesheets/application.css' do |content| expect(content).to include("@import 'components/doorkeeper.css';") end - end - it 'adds the dynamic registration controller' do - assert_file 'app/controllers/oauth_registrations_controller.rb' + # dynamic registration controller + assert_file 'app/controllers/oauth_registrations_controller.rb' do |content| + expect(content).to include('skip_before_action :authenticate_user!') + expect(content).to include('skip_after_action :verify_authorized') + expect(content).not_to include('skip_verify_authorized') + end assert_file 'spec/requests/oauth_registrations_controller_spec.rb' assert_file 'config/routes.rb' do |content| expect(content).to include("post '/oauth/register', to: 'oauth_registrations#create'") end - end - it 'adds the well-known route' do - assert_file 'app/controllers/well_known_controller.rb' + # well-known route + assert_file 'app/controllers/well_known_controller.rb' do |content| + expect(content).to include('skip_before_action :authenticate_user!') + expect(content).to include('skip_after_action :verify_authorized') + expect(content).not_to include('skip_verify_authorized') + end assert_file 'spec/requests/well_known_controller_spec.rb' assert_file 'config/routes.rb' do |content| expect(content).to include("get '/.well-known/oauth-protected-resource', to: 'well_known#oauth_protected_resource'") expect(content).to include("get '/.well-known/oauth-authorization-server', to: 'well_known#oauth_authorization_server'") end - end - it 'updates inflections' do + # Inflections assert_file 'config/initializers/inflections.rb' do |content| expect(content).to include("\nActiveSupport::Inflector.inflections(:en) do |inflect|") expect(content).to include(" inflect.acronym 'MCP'") end end + + context 'without Devise' do + let(:devise_choice) { 'no' } + + it 'adds the controllers without Devise-specific code' do + assert_file 'app/controllers/mcp_controller.rb' do |content| + expect(content).not_to include('skip_before_action :authenticate_user!') + end + assert_file 'app/controllers/oauth_registrations_controller.rb' do |content| + expect(content).not_to include('skip_before_action :authenticate_user!') + end + assert_file 'app/controllers/well_known_controller.rb' do |content| + expect(content).not_to include('skip_before_action :authenticate_user!') + end + end + end + + context 'with Action Policy' do + let(:policy_choice) { 'action_policy' } + + it 'generates Action Policy policies' do + assert_file 'app/controllers/mcp_controller.rb' do |content| + expect(content).to include('rescue_from ActionPolicy::Unauthorized, with: :unauthorized_request') + expect(content).not_to include('rescue_from Pundit::NotAuthorizedError, with: :unauthorized_request') + end + + assert_file 'app/controllers/oauth_registrations_controller.rb' do |content| + expect(content).to include('skip_before_action :authenticate_user!') + expect(content).not_to include('skip_after_action :verify_authorized') + expect(content).to include('skip_verify_authorized') + end + + assert_file 'app/controllers/well_known_controller.rb' do |content| + expect(content).to include('skip_before_action :authenticate_user!') + expect(content).not_to include('skip_after_action :verify_authorized') + expect(content).to include('skip_verify_authorized') + end + + assert_file 'spec/policies/mcp_policy_spec.rb' do |content| + expect(content).to include('describe_rule :handle?') + expect(content).not_to include('permissions :handle?') + end + end + end end From aa71caa4ba8e447c3543970a62189032ecb28651 Mon Sep 17 00:00:00 2001 From: Justin Wiebe Date: Tue, 21 Apr 2026 16:37:06 -0400 Subject: [PATCH 5/6] Enable debugging --- README.md | 1 + spec/support/helpers/example_app.rb | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a076891e..418088a9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/spec/support/helpers/example_app.rb b/spec/support/helpers/example_app.rb index fa2d62b8..b6d9413a 100644 --- a/spec/support/helpers/example_app.rb +++ b/spec/support/helpers/example_app.rb @@ -20,8 +20,8 @@ def cleanup_test_app # but does keep the test output clean and easy to read def run_generator_against_test_app(*args, generator: described_class) self.generator_class = generator - capture(:stderr) do - FileUtils.cd(destination_root) { run_generator(*args) } - end + FileUtils.cd(destination_root) { run_generator(*args) } + # capture(:stderr) do + # end end end From 0ce9a101240b1b758d975723fcb7fff6bb8d215d Mon Sep 17 00:00:00 2001 From: Justin Wiebe Date: Tue, 21 Apr 2026 17:01:30 -0400 Subject: [PATCH 6/6] Fix tests --- Gemfile | 1 + Gemfile.lock | 14 ++++++++++++++ example_rails_current/config/environments/test.rb | 6 +++--- example_rails_legacy/config/environments/test.rb | 2 +- spec/support/helpers/example_app.rb | 6 +++--- 5 files changed, 22 insertions(+), 7 deletions(-) diff --git a/Gemfile b/Gemfile index 51d969f2..dbacdcf4 100644 --- a/Gemfile +++ b/Gemfile @@ -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' diff --git a/Gemfile.lock b/Gemfile.lock index 2366c0f1..e48030af 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) @@ -284,6 +297,7 @@ DEPENDENCIES benchmark bundler (~> 4) debug (>= 1.0.0) + devise doorkeeper generator_spec (~> 0.10) mcp diff --git a/example_rails_current/config/environments/test.rb b/example_rails_current/config/environments/test.rb index c2095b11..374d7964 100644 --- a/example_rails_current/config/environments/test.rb +++ b/example_rails_current/config/environments/test.rb @@ -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 @@ -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 diff --git a/example_rails_legacy/config/environments/test.rb b/example_rails_legacy/config/environments/test.rb index 0c616a1b..0674725c 100644 --- a/example_rails_legacy/config/environments/test.rb +++ b/example_rails_legacy/config/environments/test.rb @@ -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}" } diff --git a/spec/support/helpers/example_app.rb b/spec/support/helpers/example_app.rb index b6d9413a..fa2d62b8 100644 --- a/spec/support/helpers/example_app.rb +++ b/spec/support/helpers/example_app.rb @@ -20,8 +20,8 @@ def cleanup_test_app # but does keep the test output clean and easy to read def run_generator_against_test_app(*args, generator: described_class) self.generator_class = generator - FileUtils.cd(destination_root) { run_generator(*args) } - # capture(:stderr) do - # end + capture(:stderr) do + FileUtils.cd(destination_root) { run_generator(*args) } + end end end