diff --git a/Gemfile b/Gemfile index a8b4dd98..628ae268 100644 --- a/Gemfile +++ b/Gemfile @@ -12,6 +12,8 @@ gem 'propshaft' gem 'puma' gem 'stimulus-rails' gem 'turbo-rails' +gem 'doorkeeper' +gem 'mcp' # Start debugger with binding.b [https://github.com/ruby/debug] gem 'debug', '>= 1.0.0' diff --git a/Gemfile.lock b/Gemfile.lock index 680e31c3..b4777654 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -81,6 +81,8 @@ GEM securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) + addressable (2.8.9) + public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) base64 (0.3.0) benchmark (0.5.0) @@ -94,6 +96,8 @@ GEM irb (~> 1.10) reline (>= 0.3.8) diff-lcs (1.6.2) + doorkeeper (5.9.0) + railties (>= 5) drb (2.2.3) erb (6.0.1) erubi (1.13.1) @@ -110,6 +114,9 @@ GEM rdoc (>= 4.0.0) reline (>= 0.4.2) json (2.18.0) + json-schema (6.2.0) + addressable (~> 2.8) + bigdecimal (>= 3.1, < 5) language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) @@ -123,8 +130,9 @@ GEM net-pop net-smtp marcel (1.1.0) + mcp (0.10.0) + json-schema (>= 4.1) mini_mime (1.1.5) - mini_portile2 (2.8.9) minitest (6.0.1) prism (~> 1.5) net-imap (0.6.2) @@ -137,8 +145,7 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.5) - nokogiri (1.19.0) - mini_portile2 (~> 2.8.2) + nokogiri (1.19.0-arm64-darwin) racc (~> 1.4) nokogiri (1.19.0-x86_64-linux-gnu) racc (~> 1.4) @@ -159,6 +166,7 @@ GEM psych (5.3.1) date stringio + public_suffix (7.0.5) puma (7.2.0) nio4r (~> 2.0) racc (1.8.1) @@ -268,6 +276,7 @@ GEM zeitwerk (2.7.4) PLATFORMS + arm64-darwin-23 arm64-darwin-25 x86_64-linux @@ -275,7 +284,9 @@ DEPENDENCIES benchmark bundler (~> 4) debug (>= 1.0.0) + doorkeeper generator_spec (~> 0.10) + mcp pg propshaft puma diff --git a/lib/generators/rolemodel/all_generator.rb b/lib/generators/rolemodel/all_generator.rb index 00a5864d..de7c0409 100644 --- a/lib/generators/rolemodel/all_generator.rb +++ b/lib/generators/rolemodel/all_generator.rb @@ -24,6 +24,7 @@ def run_all_the_generators generate 'rolemodel:editors' # generate 'rolemodel:tailored_select' # Not production ready generate 'rolemodel:lograge' + generate 'rolemodel:mcp' end end end diff --git a/lib/generators/rolemodel/mcp/README.md b/lib/generators/rolemodel/mcp/README.md new file mode 100644 index 00000000..4b15f0b9 --- /dev/null +++ b/lib/generators/rolemodel/mcp/README.md @@ -0,0 +1,13 @@ +# MCP Generator + +Install boilerplate for your very own MCP server. + +## What you get + +### Doorkeeper + +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. diff --git a/lib/generators/rolemodel/mcp/USAGE b/lib/generators/rolemodel/mcp/USAGE new file mode 100644 index 00000000..2fc3902b --- /dev/null +++ b/lib/generators/rolemodel/mcp/USAGE @@ -0,0 +1,8 @@ +Description: + Sets up RoleModel MCP support and any required application wiring for the MCP endpoint. + +Example: + rails generate rolemodel:mcp + + This generator adds the files and route updates needed to enable RoleModel MCP + in your Rails application. diff --git a/lib/generators/rolemodel/mcp/mcp_generator.rb b/lib/generators/rolemodel/mcp/mcp_generator.rb new file mode 100644 index 00000000..161f8c0f --- /dev/null +++ b/lib/generators/rolemodel/mcp/mcp_generator.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +module Rolemodel + class MCPGenerator < BaseGenerator + source_root File.expand_path('templates', __dir__) + + def update_inflections + inflections_path = File.join(destination_root, 'config/initializers/inflections.rb') + block_start = "\nActiveSupport::Inflector.inflections(:en) do |inflect|\n" + + return if File.read(inflections_path).include?("inflect.acronym 'MCP'") + + if File.read(inflections_path).include?(block_start) + inject_into_file inflections_path, " inflect.acronym 'MCP'\n", after: block_start + else + append_to_file inflections_path, <<~RUBY + + ActiveSupport::Inflector.inflections(:en) do |inflect| + inflect.acronym 'MCP' + end + RUBY + end + end + + def install_mcp + bundle_command 'add mcp' + template 'app/controllers/mcp_controller.rb' + copy_file 'spec/requests/mcp_controller_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/docs/SAMPLE_DOC.md' + copy_file 'app/mcp/resources/docs_controller.rb' + copy_file 'spec/mcp/resources/docs_controller_spec.rb' + end + + def add_sample_mcp_prompt + copy_file 'app/mcp/prompts/sample.rb' + copy_file 'spec/mcp/prompts/sample_spec.rb' + end + + def add_sample_mcp_tool + copy_file 'app/mcp/tools/sample.rb' + copy_file 'spec/mcp/tools/sample_spec.rb' + end + + def install_doorkeeper + bundle_command 'add doorkeeper' + generate 'doorkeeper:install' + end + + def configure_doorkeeper + copy_file 'config/initializers/doorkeeper.rb', force: true + copy_file 'app/controllers/doorkeeper/base_controller.rb' + + copy_file 'app/views/layouts/doorkeeper.html.slim' + template 'app/views/doorkeeper/authorizations/new.html.slim' + template 'app/views/doorkeeper/authorizations/error.html.slim' + + copy_file 'app/assets/stylesheets/components/doorkeeper.css' + + route 'use_doorkeeper' + end + + 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 + + return if File.read(File.join(destination_root, css_manifest)).include?("@import 'components/doorkeeper.css';") + + append_to_file css_manifest, <<~CSS + @import 'components/doorkeeper.css'; + CSS + end + + def add_oauth_dynamic_registrations + copy_file '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' + copy_file 'spec/requests/well_known_controller_spec.rb' + route <<~RUBY + get '/.well-known/oauth-protected-resource', to: 'well_known#oauth_protected_resource' + get '/.well-known/oauth-authorization-server', to: 'well_known#oauth_authorization_server' + RUBY + end + + private + + def application_name + Rails.application.class.try(:parent_name) || Rails.application.class.module_parent_name + end + end +end diff --git a/lib/generators/rolemodel/mcp/templates/app/assets/stylesheets/components/doorkeeper.css b/lib/generators/rolemodel/mcp/templates/app/assets/stylesheets/components/doorkeeper.css new file mode 100644 index 00000000..4a1862fc --- /dev/null +++ b/lib/generators/rolemodel/mcp/templates/app/assets/stylesheets/components/doorkeeper.css @@ -0,0 +1,140 @@ +.doorkeeper { + position: fixed; + inset: 0; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + padding: var(--op-space-x-large) var(--op-space-large); + overflow: auto; + background-color: var(--op-color-neutral-plus-eight); + color: var(--op-color-neutral-minus-max); + font-family: var(--op-font-family); +} + +.doorkeeper__card { + width: min(100%, 48rem); + background-color: var(--op-color-white); + border: var(--op-border-width) solid var(--op-color-neutral-plus-six); + border-radius: var(--op-radius-x-large); + box-shadow: var(--op-shadow-large); + display: flex; + flex-direction: column; + gap: var(--op-space-large); + padding: var(--op-space-2x-large); +} + +.doorkeeper__brand { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--op-space-large); + margin-bottom: var(--op-space-small); +} + +.doorkeeper__logo { + width: 180px; + height: auto; +} + +.doorkeeper__title { + margin: 0; + font-size: var(--op-font-2x-large); + font-weight: var(--op-font-weight-bold); + color: var(--op-color-primary-minus-two); + text-align: center; + letter-spacing: -0.04em; +} + +.doorkeeper__prompt { + margin: 0; + font-size: var(--op-font-large); + line-height: var(--op-line-height-loose); + color: var(--op-color-neutral-minus-three); + text-align: center; +} + +.doorkeeper__client-name { + color: var(--op-color-primary-base); + font-weight: var(--op-font-weight-bold); +} + +.doorkeeper__permissions { + background-color: var(--op-color-primary-plus-eight); + border: var(--op-border-width) solid var(--op-color-primary-plus-six); + border-radius: var(--op-radius-medium); + padding: var(--op-space-large); + display: flex; + flex-direction: column; + gap: var(--op-space-medium); +} + +.doorkeeper__permissions-label { + margin: 0; + font-size: var(--op-font-small); + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: var(--op-font-weight-bold); + color: var(--op-color-primary-minus-three); +} + +.doorkeeper__scope-list { + margin: 0; + padding-left: var(--op-space-large); + display: grid; + gap: var(--op-space-small); + color: var(--op-color-primary-minus-max); +} + +.doorkeeper__scope-item { + line-height: var(--op-line-height-base); + font-weight: var(--op-font-weight-medium); +} + +.doorkeeper__actions { + display: flex; + flex-direction: column; + gap: var(--op-space-medium); + margin-top: var(--op-space-medium); +} + +.doorkeeper__error { + align-self: stretch; +} + +.doorkeeper__error-description { + margin: 0; + font-size: var(--op-font-medium); + line-height: var(--op-line-height-base); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.doorkeeper__form { + margin: 0; +} + +.doorkeeper__button { + width: 100%; + justify-content: center; + font-weight: var(--op-font-weight-semi-bold); +} + +@media (max-width: 640px) { + .doorkeeper { + padding: var(--op-space-large) var(--op-space-medium); + } + + .doorkeeper__card { + padding: var(--op-space-large); + border-radius: var(--op-radius-large); + } + + .doorkeeper__logo { + width: 140px; + } + + .doorkeeper__title { + font-size: var(--op-font-x-large); + } +} diff --git a/lib/generators/rolemodel/mcp/templates/app/controllers/doorkeeper/base_controller.rb b/lib/generators/rolemodel/mcp/templates/app/controllers/doorkeeper/base_controller.rb new file mode 100644 index 00000000..626b7e34 --- /dev/null +++ b/lib/generators/rolemodel/mcp/templates/app/controllers/doorkeeper/base_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Doorkeeper + class BaseController < ::ApplicationController + skip_forgery_protection + 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 new file mode 100644 index 00000000..b2606e66 --- /dev/null +++ b/lib/generators/rolemodel/mcp/templates/app/controllers/mcp_controller.rb.tt @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +class MCPController < ApplicationController + # skip_before_action :authenticate_user! + skip_forgery_protection + + before_action :authorize_mcp + before_action :set_current_user + + def handle + server = build_server + transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true) + server.transport = transport + + status, response_headers, body = transport.handle_request(request) + respond_with(status, response_headers, body) + end + + private + + def respond_with(status, headers, body) + headers.each { |key, value| response.set_header(key, value) } + response.status = status + self.response_body = body + end + + def authorize_mcp + doorkeeper_authorize! :mcp + set_mcp_resource_metadata_header if response.status == 401 + end + + def set_current_user + @current_user = User.find_by(id: doorkeeper_token&.resource_owner_id) + unauthorized_request if @current_user.blank? + end + + def unauthorized_request + set_mcp_resource_metadata_header + render json: { error: 'Unauthorized' }, status: :unauthorized + end + + def set_mcp_resource_metadata_header + metadata = %(resource_metadata="#{request.base_url}/.well-known/oauth-protected-resource") + response.set_header('WWW-Authenticate', %(Bearer realm="<%= application_name.titleize %>", #{metadata})) + end + + def build_server + server = MCP::Server.new(**mcp_server_config) + handle_resources(server) + + server + end + + def handle_resources(server) # rubocop:disable Metrics/MethodLength + controllers = [ + Resources::DocsController + ] + + server.resources_read_handler do |params| + uri = params[:uri].to_s + controller = controllers.find { |h| h.serves?(uri) } + + unless controller + raise MCP::Server::RequestHandlerError.new( + "Unable to serve resource for URI: #{uri}. Supported schemas: #{controllers.map(&:schema).join(', ')}", + params, + error_type: :invalid_params + ) + end + + controller.call(params, server_context) + end + end + + def mcp_server_config # rubocop:disable Metrics/MethodLength + { + name: '<%= application_name.underscore %>_mcp', + version: '1.0.0', + tools: [Tools::Sample], + prompts: [Prompts::Sample], + server_context:, + resources: [ + *Resources::DocsController.resource_list, + ], + } + end + + def server_context + @server_context ||= { current_user: @current_user } + end +end 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 new file mode 100644 index 00000000..6b548327 --- /dev/null +++ b/lib/generators/rolemodel/mcp/templates/app/controllers/oauth_registrations_controller.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class OauthRegistrationsController < ApplicationController + # skip_before_action :authenticate_user! + skip_forgery_protection + + def create + app = Doorkeeper::Application.new(doorkeeper_params) + return client_metadata_error('redirect_uris is required') if app.redirect_uri.blank? + + if app.save + render json: base_response(app), status: :created + else + client_metadata_error(app.errors.full_messages.join(', ')) + end + end + + private + + def base_response(app) + { + client_id: app.uid, + client_name: app.name, + redirect_uris: app.redirect_uri.split("\n"), + grant_types: %w[authorization_code refresh_token], + response_types: ['code'], + token_endpoint_auth_method: app.confidential? ? 'client_secret_basic' : 'none', + client_id_issued_at: app.created_at.to_i, + scope: 'mcp', + client_secret: app.confidential? ? app.secret : nil, + }.compact + end + + def client_metadata_error(description) + render json: { error: 'invalid_client_metadata', error_description: description }, status: :bad_request + end + + def doorkeeper_params + { + name: params[:client_name].presence || 'MCP Client', + redirect_uri: params[:redirect_uris].is_a?(Array) ? params[:redirect_uris].join("\n") : params[:redirect_uris], + scopes: 'mcp', + confidential: params[:token_endpoint_auth_method] != 'none', + } + end +end 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 new file mode 100644 index 00000000..53063bf7 --- /dev/null +++ b/lib/generators/rolemodel/mcp/templates/app/controllers/well_known_controller.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class WellKnownController < ApplicationController + # skip_before_action :authenticate_user! + before_action :set_base_url + + def oauth_protected_resource + render json: { + resource: "#{@base_url}/mcp", + authorization_servers: [@base_url], + } + end + + def oauth_authorization_server + render json: authorization_server_metadata + end + + private + + def authorization_server_metadata # rubocop:disable Metrics/MethodLength + { + issuer: @base_url, + authorization_endpoint: "#{@base_url}/oauth/authorize", + token_endpoint: "#{@base_url}/oauth/token", + registration_endpoint: "#{@base_url}/oauth/register", + revocation_endpoint: "#{@base_url}/oauth/revoke", + introspection_endpoint: "#{@base_url}/oauth/introspect", + scopes_supported: ['mcp'], + response_types_supported: ['code'], + grant_types_supported: %w[authorization_code client_credentials refresh_token], + token_endpoint_auth_methods_supported: %w[none client_secret_basic client_secret_post], + code_challenge_methods_supported: ['S256'], + } + end + + def set_base_url + @base_url = request.base_url + end +end diff --git a/lib/generators/rolemodel/mcp/templates/app/mcp/prompts/sample.rb b/lib/generators/rolemodel/mcp/templates/app/mcp/prompts/sample.rb new file mode 100644 index 00000000..8d34f3ba --- /dev/null +++ b/lib/generators/rolemodel/mcp/templates/app/mcp/prompts/sample.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Prompts + class Sample < ::MCP::Prompt + prompt_name 'sample_prompt' + title 'Sample Prompt' + description 'Sample prompt description' + + class << self + def template(_args, _server_context: nil) + ::MCP::Prompt::Result.new( + description: 'Sample prompt result description', + messages: [ + ::MCP::Prompt::Message.new( + role: 'assistant', + content: ::MCP::Content::Text.new(instructions_text), + ), + ], + ) + end + + private + + def instructions_text + <<~TEXT + This is a sample prompt. + + MCP prompts can return instructions for the agent, which can be used to guide the agent's behavior. + For example, you might include instructions on how to query a specific resource or use a specific tool. + Think of it like a system prompt in a conversational agent, but it can be dynamically generated based on the + context of the request. + TEXT + end + end + end +end diff --git a/lib/generators/rolemodel/mcp/templates/app/mcp/resources/controller.rb b/lib/generators/rolemodel/mcp/templates/app/mcp/resources/controller.rb new file mode 100644 index 00000000..2bde96c8 --- /dev/null +++ b/lib/generators/rolemodel/mcp/templates/app/mcp/resources/controller.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Resources + class Controller + include ActiveModel::Attributes + include ActiveModel::API + + attribute :server_context + attribute :path, :string + + validates :path, presence: { message: 'is required' } # rubocop:disable Rails/I18nLocaleTexts + + class << self + def mime_type(mime_type = nil) + @mime_type = mime_type if mime_type + @mime_type + end + + def schema(schema = nil) + @schema = schema if schema + @schema + end + + def serves?(uri) + uri.start_with?(schema) + end + + def call(params, server_context) + controller = new(params[:uri].sub(schema, ''), server_context) + + unless controller.valid? + raise ::MCP::Server::RequestHandlerError.new( + controller.errors.full_messages.join(', '), + params, + error_type: :invalid_params + ) + end + + [{ uri: params[:uri], mimeType: mime_type, text: controller.serve }] + end + end + + def initialize(path, server_context = nil) + super() + self.path = path + self.server_context = server_context + end + + private + + def no_extra_path_parts + return if @extra.blank? + + errors.add(:base, "Too many uri parts: #{@extra.join('/')}.") + end + end +end diff --git a/lib/generators/rolemodel/mcp/templates/app/mcp/resources/docs/SAMPLE_DOC.md b/lib/generators/rolemodel/mcp/templates/app/mcp/resources/docs/SAMPLE_DOC.md new file mode 100644 index 00000000..6e3268aa --- /dev/null +++ b/lib/generators/rolemodel/mcp/templates/app/mcp/resources/docs/SAMPLE_DOC.md @@ -0,0 +1,4 @@ +# Hello World + +This is a sample doc that the MCP can return as a resource. +It's URI will be doc://SAMPLE_DOC.md as set in the handler. 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 new file mode 100644 index 00000000..617ff3da --- /dev/null +++ b/lib/generators/rolemodel/mcp/templates/app/mcp/resources/docs_controller.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Resources + class DocsController < Controller + FILES = { + 'SAMPLE_DOC.md' => Rails.root.join('app/mcp/resources/docs/blazer-documentation.md'), + }.freeze + + mime_type 'text/markdown' + schema 'docs://' + + attribute :file_path + + validates :file_path, presence: { message: ->(controller, _) { "Unknown docs resource: #{controller.path}" } } + validate :file_exists + + def initialize(path, _server_context = nil) + super + self.file_path = FILES[path] + end + + def self.resource_list + [ + ::MCP::Resource.new( + uri: 'docs://SAMPLE_DOC.md', + name: 'sample_doc', + title: 'Sample Resource', + description: 'Sample resource', + mime_type: mime_type, + ), + ] + end + + def serve + file_path.read + end + + private + + def file_exists + return if file_path.blank? || file_path.exist? + + errors.add(:file_path, "Missing docs file for #{path}") + end + end +end diff --git a/lib/generators/rolemodel/mcp/templates/app/mcp/tools/sample.rb b/lib/generators/rolemodel/mcp/templates/app/mcp/tools/sample.rb new file mode 100644 index 00000000..9ec4b65f --- /dev/null +++ b/lib/generators/rolemodel/mcp/templates/app/mcp/tools/sample.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Tools + class Sample < ::MCP::Tool + tool_name 'sample_tool' + title 'Sample Tool' + description 'Sample tool description' + input_schema( + properties: { + name: { type: 'string', minLength: 1 }, + }, + required: ['name'], + ) + annotations( + read_only_hint: true, + destructive_hint: false, + idempotent_hint: true, + open_world_hint: false, + title: 'Sample Tool', + ) + + class << self + def call(name:, server_context:) + payload = payload_for(name) + + ::MCP::Tool::Response.new( + [{ type: 'text', text: payload.to_json }], + structured_content: { sample: payload }, + ) + end + + private + + def payload_for(name) + { + time: Time.current.iso8601, + user_count: User.count + } + end + end + end +end diff --git a/lib/generators/rolemodel/mcp/templates/app/views/doorkeeper/authorizations/error.html.slim.tt b/lib/generators/rolemodel/mcp/templates/app/views/doorkeeper/authorizations/error.html.slim.tt new file mode 100644 index 00000000..0994caad --- /dev/null +++ b/lib/generators/rolemodel/mcp/templates/app/views/doorkeeper/authorizations/error.html.slim.tt @@ -0,0 +1,13 @@ +- content_for :page_title do + = '<%= application_name.titleize %> - Error Authorizing Application' + +main.doorkeeper role="main" + .doorkeeper__card.card.card--padded.card--shadow-medium + .doorkeeper__brand + = image_tag 'logo.svg', alt: '<%= application_name.titleize %>', class: 'doorkeeper__logo' + h1.doorkeeper__title= t('doorkeeper.authorizations.error.title') + + .doorkeeper__error.alert.alert--danger role="alert" + .alert__messages + p.doorkeeper__error-description.alert__description + = (local_assigns[:error_response] ? error_response : @pre_auth.error_response).body[:error_description] diff --git a/lib/generators/rolemodel/mcp/templates/app/views/doorkeeper/authorizations/new.html.slim.tt b/lib/generators/rolemodel/mcp/templates/app/views/doorkeeper/authorizations/new.html.slim.tt new file mode 100644 index 00000000..cd0feb3d --- /dev/null +++ b/lib/generators/rolemodel/mcp/templates/app/views/doorkeeper/authorizations/new.html.slim.tt @@ -0,0 +1,41 @@ +- content_for :page_title do + = '<%= application_name.titleize %> - Authorize Application' + +main.doorkeeper role="main" + .doorkeeper__card.card.card--padded.card--shadow-medium + .doorkeeper__brand + = image_tag 'logo.svg', alt: '<%= application_name.titleize %>', class: 'doorkeeper__logo' + h1.doorkeeper__title= t('.title') + + p.doorkeeper__prompt + == t('.prompt', client_name: content_tag(:strong, @pre_auth.client.name, class: 'doorkeeper__client-name')) + + - if @pre_auth.scopes.count > 0 + #oauth-permissions.doorkeeper__permissions + p.doorkeeper__permissions-label= t('.able_to') + ":" + ul.doorkeeper__scope-list + - @pre_auth.scopes.each do |scope| + li.doorkeeper__scope-item= t scope, scope: [:doorkeeper, :scopes] + + .doorkeeper__actions + = form_tag oauth_authorization_path, method: :post, class: 'doorkeeper__form', data: { turbo: false } do + = hidden_field_tag :client_id, @pre_auth.client.uid, id: nil + = hidden_field_tag :redirect_uri, @pre_auth.redirect_uri, id: nil + = hidden_field_tag :state, @pre_auth.state, id: nil + = hidden_field_tag :response_type, @pre_auth.response_type, id: nil + = hidden_field_tag :response_mode, @pre_auth.response_mode, id: nil + = hidden_field_tag :scope, @pre_auth.scope, id: nil + = hidden_field_tag :code_challenge, @pre_auth.code_challenge, id: nil + = hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method, id: nil + = submit_tag t('doorkeeper.authorizations.buttons.authorize'), class: 'btn btn--primary doorkeeper__button' + + = form_tag oauth_authorization_path, method: :delete, class: 'doorkeeper__form' do + = hidden_field_tag :client_id, @pre_auth.client.uid, id: nil + = hidden_field_tag :redirect_uri, @pre_auth.redirect_uri, id: nil + = hidden_field_tag :state, @pre_auth.state, id: nil + = hidden_field_tag :response_type, @pre_auth.response_type, id: nil + = hidden_field_tag :response_mode, @pre_auth.response_mode, id: nil + = hidden_field_tag :scope, @pre_auth.scope, id: nil + = hidden_field_tag :code_challenge, @pre_auth.code_challenge, id: nil + = hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method, id: nil + = submit_tag t('doorkeeper.authorizations.buttons.deny'), class: 'btn btn--destructive doorkeeper__button' diff --git a/lib/generators/rolemodel/mcp/templates/app/views/layouts/doorkeeper.html.slim b/lib/generators/rolemodel/mcp/templates/app/views/layouts/doorkeeper.html.slim new file mode 100644 index 00000000..45050508 --- /dev/null +++ b/lib/generators/rolemodel/mcp/templates/app/views/layouts/doorkeeper.html.slim @@ -0,0 +1,7 @@ +doctype html +html + / Use whatever partial you have for the head + = render 'head' + + body + = yield diff --git a/lib/generators/rolemodel/mcp/templates/config/initializers/doorkeeper.rb b/lib/generators/rolemodel/mcp/templates/config/initializers/doorkeeper.rb new file mode 100644 index 00000000..d80626c3 --- /dev/null +++ b/lib/generators/rolemodel/mcp/templates/config/initializers/doorkeeper.rb @@ -0,0 +1,537 @@ +# frozen_string_literal: true + +Rails.application.config.to_prepare do + Doorkeeper::AuthorizationsController.layout 'doorkeeper' +end + +Doorkeeper.configure do + # Change the ORM that doorkeeper will use (requires ORM extensions installed). + # Check the list of supported ORMs here: https://github.com/doorkeeper-gem/doorkeeper#orms + orm :active_record + + # Enable support for multiple database configurations with read replicas. + # When enabled, Doorkeeper will wrap database write operations to ensure they + # use the primary (writable) database when automatic role switching is enabled. + # + # For ActiveRecord (Rails 6.1+), this uses `ActiveRecord::Base.connected_to(role: :writing)`. + # Other ORM extensions can implement their own primary database targeting logic. + # + # enable_multiple_database_roles + # + # This prevents `ActiveRecord::ReadOnlyError` when using read replicas with Rails + # automatic role switching. Enable this if your application uses multiple databases + # with automatic role switching for read replicas. + # + # See: https://guides.rubyonrails.org/active_record_multiple_databases.html#activating-automatic-role-switching + + # This block will be called to check whether the resource owner is authenticated or not. + resource_owner_authenticator do + current_user || warden.authenticate!(scope: :user) + end + + # If you didn't skip applications controller from Doorkeeper routes in your application routes.rb + # file then you need to declare this block in order to restrict access to the web interface for + # adding oauth authorized applications. In other case it will return 403 Forbidden response + # every time somebody will try to access the admin web interface. + # + admin_authenticator do + current_user || warden.authenticate!(scope: :user) + end + + # You can use your own model classes if you need to extend (or even override) default + # Doorkeeper models such as `Application`, `AccessToken` and `AccessGrant. + # + # By default Doorkeeper ActiveRecord ORM uses its own classes: + # + # access_token_class "Doorkeeper::AccessToken" + # access_grant_class "Doorkeeper::AccessGrant" + # application_class "Doorkeeper::Application" + # + # Don't forget to include Doorkeeper ORM mixins into your custom models: + # + # * ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessToken - for access token + # * ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessGrant - for access grant + # * ::Doorkeeper::Orm::ActiveRecord::Mixins::Application - for application (OAuth2 clients) + # + # For example: + # + # access_token_class "MyAccessToken" + # + # class MyAccessToken < ApplicationRecord + # include ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessToken + # + # self.table_name = "hey_i_wanna_my_name" + # + # def destroy_me! + # destroy + # end + # end + + # Enables polymorphic Resource Owner association for Access Tokens and Access Grants. + # By default this option is disabled. + # + # Make sure you properly setup you database and have all the required columns (run + # `bundle exec rails generate doorkeeper:enable_polymorphic_resource_owner` and execute Rails + # migrations). + # + # If this option enabled, Doorkeeper will store not only Resource Owner primary key + # value, but also it's type (class name). See "Polymorphic Associations" section of + # Rails guides: https://guides.rubyonrails.org/association_basics.html#polymorphic-associations + # + # [NOTE] If you apply this option on already existing project don't forget to manually + # update `resource_owner_type` column in the database and fix migration template as it will + # set NOT NULL constraint for Access Grants table. + # + # use_polymorphic_resource_owner + + # If you are planning to use Doorkeeper in Rails 5 API-only application, then you might + # want to use API mode that will skip all the views management and change the way how + # Doorkeeper responds to a requests. + # + # api_only + + # Enforce token request content type to application/x-www-form-urlencoded. + # It is not enabled by default to not break prior versions of the gem. + # + # enforce_content_type + + # Authorization Code expiration time (default: 10 minutes). + # + # authorization_code_expires_in 10.minutes + + # Access token expiration time (default: 2 hours). + # If you set this to `nil` Doorkeeper will not expire the token and omit expires_in in response. + # It is RECOMMENDED to set expiration time explicitly. + # Prefer access_token_expires_in 100.years or similar, + # which would be functionally equivalent and avoid the risk of unexpected behavior by callers. + # + # access_token_expires_in 2.hours + + # Assign custom TTL for access tokens. Will be used instead of access_token_expires_in + # option if defined. In case the block returns `nil` value Doorkeeper fallbacks to + # +access_token_expires_in+ configuration option value. If you really need to issue a + # non-expiring access token (which is not recommended) then you need to return + # Float::INFINITY from this block. + # + # `context` has the following properties available: + # + # * `client` - the OAuth client application (see Doorkeeper::OAuth::Client) + # * `grant_type` - the grant type of the request (see Doorkeeper::OAuth) + # * `scopes` - the requested scopes (see Doorkeeper::OAuth::Scopes) + # * `resource_owner` - authorized resource owner instance (if present) + # + # custom_access_token_expires_in do |context| + # context.client.additional_settings.implicit_oauth_expiration + # end + + # Use a custom class for generating the access token. + # See https://doorkeeper.gitbook.io/guides/configuration/other-configurations#custom-access-token-generator + # + # access_token_generator '::Doorkeeper::JWT' + + # The controller +Doorkeeper::ApplicationController+ inherits from. + # Defaults to +ActionController::Base+ unless +api_only+ is set, which changes the default to + # +ActionController::API+. The return value of this option must be a stringified class name. + # See https://doorkeeper.gitbook.io/guides/configuration/other-configurations#custom-controllers + # + base_controller 'Doorkeeper::BaseController' + + # Reuse access token for the same resource owner within an application (disabled by default). + # + # This option protects your application from creating new tokens before old **valid** one becomes + # expired so your database doesn't bloat. Keep in mind that when this option is enabled Doorkeeper + # doesn't update existing token expiration time, it will create a new token instead if no active matching + # token found for the application, resources owner and/or set of scopes. + # Rationale: https://github.com/doorkeeper-gem/doorkeeper/issues/383 + # + # You can not enable this option together with +hash_token_secrets+. + # + # reuse_access_token + + # In case you enabled `reuse_access_token` option Doorkeeper will try to find matching + # token using `matching_token_for` Access Token API that searches for valid records + # in batches in order not to pollute the memory with all the database records. By default + # Doorkeeper uses batch size of 10 000 records. You can increase or decrease this value + # depending on your needs and server capabilities. + # + # token_lookup_batch_size 10_000 + + # Set a limit for token_reuse if using reuse_access_token option + # + # This option limits token_reusability to some extent. + # If not set then access_token will be reused unless it expires. + # Rationale: https://github.com/doorkeeper-gem/doorkeeper/issues/1189 + # + # This option should be a percentage(i.e. (0,100]) + # + # token_reuse_limit 100 + + # Only allow one valid access token obtained via client credentials + # per client. If a new access token is obtained before the old one + # expired, the old one gets revoked (disabled by default) + # + # When enabling this option, make sure that you do not expect multiple processes + # using the same credentials at the same time (e.g. web servers spanning + # multiple machines and/or processes). + # + # revoke_previous_client_credentials_token + + # Only allow one valid access token obtained via authorization code + # per client. If a new access token is obtained before the old one + # expired, the old one gets revoked (disabled by default) + # + # revoke_previous_authorization_code_token + + # Require non-confidential clients to use PKCE when using an authorization code + # to obtain an access_token (disabled by default) + # + force_pkce + + # Hash access and refresh tokens before persisting them. + # This will disable the possibility to use +reuse_access_token+ + # since plain values can no longer be retrieved. + # + # Note: If you are already a user of doorkeeper and have existing tokens + # in your installation, they will be invalid without adding 'fallback: :plain'. + # + # hash_token_secrets + # By default, token secrets will be hashed using the + # +Doorkeeper::Hashing::SHA256+ strategy. + # + # If you wish to use another hashing implementation, you can override + # this strategy as follows: + # + # hash_token_secrets using: '::Doorkeeper::Hashing::MyCustomHashImpl' + # + # Keep in mind that changing the hashing function will invalidate all existing + # secrets, if there are any. + + # Hash application secrets before persisting them. + # + # hash_application_secrets + # + # By default, applications will be hashed + # with the +Doorkeeper::SecretStoring::SHA256+ strategy. + # + # If you wish to use bcrypt for application secret hashing, uncomment + # this line instead: + # + # hash_application_secrets using: '::Doorkeeper::SecretStoring::BCrypt' + + # When the above option is enabled, and a hashed token or secret is not found, + # you can allow to fall back to another strategy. For users upgrading + # doorkeeper and wishing to enable hashing, you will probably want to enable + # the fallback to plain tokens. + # + # This will ensure that old access tokens and secrets + # will remain valid even if the hashing above is enabled. + # + # This can be done by adding 'fallback: plain', e.g. : + # + # hash_application_secrets using: '::Doorkeeper::SecretStoring::BCrypt', fallback: :plain + + # Issue access tokens with refresh token (disabled by default), you may also + # pass a block which accepts `context` to customize when to give a refresh + # token or not. Similar to +custom_access_token_expires_in+, `context` has + # the following properties: + # + # `client` - the OAuth client application (see Doorkeeper::OAuth::Client) + # `grant_type` - the grant type of the request (see Doorkeeper::OAuth) + # `scopes` - the requested scopes (see Doorkeeper::OAuth::Scopes) + # + use_refresh_token + + # Provide support for an owner to be assigned to each registered application (disabled by default) + # Optional parameter confirmation: true (default: false) if you want to enforce ownership of + # a registered application + # NOTE: you must also run the rails g doorkeeper:application_owner generator + # to provide the necessary support + # + # enable_application_owner confirmation: false + + # Define access token scopes for your provider + # For more information go to + # https://doorkeeper.gitbook.io/guides/ruby-on-rails/scopes + # + default_scopes :mcp + + # Allows to restrict only certain scopes for grant_type. + # By default, all the scopes will be available for all the grant types. + # + # Keys to this hash should be the name of grant_type and + # values should be the array of scopes for that grant type. + # Note: scopes should be from configured_scopes (i.e. default or optional) + # + # scopes_by_grant_type password: [:write], client_credentials: [:update] + + # Forbids creating/updating applications with arbitrary scopes that are + # not in configuration, i.e. +default_scopes+ or +optional_scopes+. + # (disabled by default) + # + # enforce_configured_scopes + + # Change the way client credentials are retrieved from the request object. + # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then + # falls back to the `:client_id` and `:client_secret` params from the `params` object. + # Check out https://github.com/doorkeeper-gem/doorkeeper/wiki/Changing-how-clients-are-authenticated + # for more information on customization + # + # client_credentials :from_basic, :from_params + + # Change the way access token is authenticated from the request object. + # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then + # falls back to the `:access_token` or `:bearer_token` params from the `params` object. + # Check out https://github.com/doorkeeper-gem/doorkeeper/wiki/Changing-how-clients-are-authenticated + # for more information on customization + # + # access_token_methods :from_bearer_authorization, :from_access_token_param, :from_bearer_param + + # Forces the usage of the HTTPS protocol in non-native redirect uris (enabled + # by default in non-development environments). OAuth2 delegates security in + # communication to the HTTPS protocol so it is wise to keep this enabled. + # + # Callable objects such as proc, lambda, block or any object that responds to + # #call can be used in order to allow conditional checks (to allow non-SSL + # redirects to localhost for example). + # + force_ssl_in_redirect_uri do |uri| + Rails.env.production? && !(uri.host == 'localhost' || uri.host == '127.0.0.1' || uri.host == '::1') + end + + # Specify what redirect URI's you want to block during Application creation. + # Any redirect URI is allowed by default. + # + # You can use this option in order to forbid URI's with 'javascript' scheme + # for example. + # + # forbid_redirect_uri { |uri| uri.scheme.to_s.downcase == 'javascript' } + + # Allows to set blank redirect URIs for Applications in case Doorkeeper configured + # to use URI-less OAuth grant flows like Client Credentials or Resource Owner + # Password Credentials. The option is on by default and checks configured grant + # types, but you **need** to manually drop `NOT NULL` constraint from `redirect_uri` + # column for `oauth_applications` database table. + # + # You can completely disable this feature with: + # + # allow_blank_redirect_uri false + # + # Or you can define your custom check: + # + # allow_blank_redirect_uri do |grant_flows, client| + # client.superapp? + # end + + # Specify how authorization errors should be handled. + # By default, doorkeeper renders json errors when access token + # is invalid, expired, revoked or has invalid scopes. + # + # If you want to render error response yourself (i.e. rescue exceptions), + # set +handle_auth_errors+ to `:raise` and rescue Doorkeeper::Errors::InvalidToken + # or following specific errors: + # + # Doorkeeper::Errors::TokenForbidden, Doorkeeper::Errors::TokenExpired, + # Doorkeeper::Errors::TokenRevoked, Doorkeeper::Errors::TokenUnknown + # + # handle_auth_errors :raise + # + # If you want to redirect back to the client application in accordance with + # https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1, you can set + # +handle_auth_errors+ to :redirect + # + # handle_auth_errors :redirect + + # Customize token introspection response. + # Allows to add your own fields to default one that are required by the OAuth spec + # for the introspection response. It could be `sub`, `aud` and so on. + # This configuration option can be a proc, lambda or any Ruby object responds + # to `.call` method and result of it's invocation must be a Hash. + # + # custom_introspection_response do |token, context| + # { + # "sub": "Z5O3upPC88QrAjx00dis", + # "aud": "https://protected.example.net/resource", + # "username": User.find(token.resource_owner_id).username + # } + # end + # + # or + # + # custom_introspection_response CustomIntrospectionResponder + + # Specify what grant flows are enabled in array of Strings. The valid + # strings and the flows they enable are: + # + # "authorization_code" => Authorization Code Grant Flow + # "implicit" => Implicit Grant Flow + # "password" => Resource Owner Password Credentials Grant Flow + # "client_credentials" => Client Credentials Grant Flow + # + # If not specified, Doorkeeper enables authorization_code and + # client_credentials. + # + # implicit and password grant flows have risks that you should understand + # before enabling: + # https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.2 + # https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.3 + # + # grant_flows %w[authorization_code client_credentials] + + # Allows to customize OAuth grant flows that +each+ application support. + # You can configure a custom block (or use a class respond to `#call`) that must + # return `true` in case Application instance supports requested OAuth grant flow + # during the authorization request to the server. This configuration +doesn't+ + # set flows per application, it only allows to check if application supports + # specific grant flow. + # + # For example you can add an additional database column to `oauth_applications` table, + # say `t.array :grant_flows, default: []`, and store allowed grant flows that can + # be used with this application there. Then when authorization requested Doorkeeper + # will call this block to check if specific Application (passed with client_id and/or + # client_secret) is allowed to perform the request for the specific grant type + # (authorization, password, client_credentials, etc). + # + # Example of the block: + # + # ->(flow, client) { client.grant_flows.include?(flow) } + # + # In case this option invocation result is `false`, Doorkeeper server returns + # :unauthorized_client error and stops the request. + # + # @param allow_grant_flow_for_client [Proc] Block or any object respond to #call + # @return [Boolean] `true` if allow or `false` if forbid the request + # + # allow_grant_flow_for_client do |grant_flow, client| + # # `grant_flows` is an Array column with grant + # # flows that application supports + # + # client.grant_flows.include?(grant_flow) + # end + + # If you need arbitrary Resource Owner-Client authorization you can enable this option + # and implement the check your need. Config option must respond to #call and return + # true in case resource owner authorized for the specific application or false in other + # cases. + # + # By default all Resource Owners are authorized to any Client (application). + # + # authorize_resource_owner_for_client do |client, resource_owner| + # resource_owner.admin? || client.owners_allowlist.include?(resource_owner) + # end + + # Allows additional data fields to be sent while granting access to an application, + # and for this additional data to be included in subsequently generated access tokens. + # The 'authorizations/new' page will need to be overridden to include this additional data + # in the request params when granting access. The access grant and access token models + # will both need to respond to these additional data fields, and have a database column + # to store them in. + # + # Example: + # You have a multi-tenanted platform and want to be able to grant access to a specific + # tenant, rather than all the tenants a user has access to. You can use this config + # option to specify that a ':tenant_id' will be passed when authorizing. This tenant_id + # will be included in the access tokens. When a request is made with one of these access + # tokens, you can check that the requested data belongs to the specified tenant. + # + # Default value is an empty Array: [] + # custom_access_token_attributes [:tenant_id] + + # Hook into the strategies' request & response life-cycle in case your + # application needs advanced customization or logging: + # + # before_successful_strategy_response do |request| + # puts "BEFORE HOOK FIRED! #{request}" + # end + # + # after_successful_strategy_response do |request, response| + # puts "AFTER HOOK FIRED! #{request}, #{response}" + # end + + # Hook into Authorization flow in order to implement Single Sign Out + # or add any other functionality. Inside the block you have an access + # to `controller` (authorizations controller instance) and `context` + # (Doorkeeper::OAuth::Hooks::Context instance) which provides pre auth + # or auth objects with issued token based on hook type (before or after). + # + # before_successful_authorization do |controller, context| + # Rails.logger.info(controller.request.params.inspect) + # + # Rails.logger.info(context.pre_auth.inspect) + # end + # + # after_successful_authorization do |controller, context| + # controller.session[:logout_urls] << + # Doorkeeper::Application + # .find_by(controller.request.params.slice(:redirect_uri)) + # .logout_uri + # + # Rails.logger.info(context.auth.inspect) + # Rails.logger.info(context.issued_token) + # end + + # Under some circumstances you might want to have applications auto-approved, + # so that the user skips the authorization step. + # For example if dealing with a trusted application. + # + # skip_authorization do |resource_owner, client| + # client.superapp? or resource_owner.admin? + # end + + # Configure custom constraints for the Token Introspection request. + # By default this configuration option allows to introspect a token by another + # token of the same application, OR to introspect the token that belongs to + # authorized client (from authenticated client) OR when token doesn't + # belong to any client (public token). Otherwise requester has no access to the + # introspection and it will return response as stated in the RFC. + # + # Block arguments: + # + # @param token [Doorkeeper::AccessToken] + # token to be introspected + # + # @param authorized_client [Doorkeeper::Application] + # authorized client (if request is authorized using Basic auth with + # Client Credentials for example) + # + # @param authorized_token [Doorkeeper::AccessToken] + # Bearer token used to authorize the request + # + # In case the block returns `nil` or `false` introspection responses with 401 status code + # when using authorized token to introspect, or you'll get 200 with { "active": false } body + # when using authorized client to introspect as stated in the + # RFC 7662 section 2.2. Introspection Response. + # + # Using with caution: + # Keep in mind that these three parameters pass to block can be nil as following case: + # `authorized_client` is nil if and only if `authorized_token` is present, and vice versa. + # `token` will be nil if and only if `authorized_token` is present. + # So remember to use `&` or check if it is present before calling method on + # them to make sure you doesn't get NoMethodError exception. + # + # You can define your custom check: + # + # allow_token_introspection do |token, authorized_client, authorized_token| + # if authorized_token + # # customize: require `introspection` scope + # authorized_token.application == token&.application || + # authorized_token.scopes.include?("introspection") + # elsif token.application + # # `protected_resource` is a new database boolean column, for example + # authorized_client == token.application || authorized_client.protected_resource? + # else + # # public token (when token.application is nil, token doesn't belong to any application) + # true + # end + # end + # + # Or you can completely disable any token introspection: + # + # allow_token_introspection false + # + # If you need to block the request at all, then configure your routes.rb or web-server + # like nginx to forbid the request. + + # WWW-Authenticate Realm (default: "Doorkeeper"). + # + # realm "Doorkeeper" +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 new file mode 100644 index 00000000..6c9e1e9f --- /dev/null +++ b/lib/generators/rolemodel/mcp/templates/spec/mcp/prompts/sample_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Prompts::Sample do + describe '.template' do + subject(:result) { described_class.template({}) } + + 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.') + 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 new file mode 100644 index 00000000..c530958d --- /dev/null +++ b/lib/generators/rolemodel/mcp/templates/spec/mcp/resources/controller_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Resources::Controller do + it 'returns error messages when invalid' do + 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 + described_class.call({ uri: 'docs://missing-doc' }, { current_user: nil }) + end.to raise_error( + MCP::Server::RequestHandlerError, + /Invalid params/ + ) + 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_controller_spec.rb new file mode 100644 index 00000000..887406ed --- /dev/null +++ b/lib/generators/rolemodel/mcp/templates/spec/mcp/resources/docs_controller_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Resources::DocsController do + describe 'class methods' do + it 'returns the correct values' do + expect(described_class.schema).to eq('docs://') + expect(described_class.mime_type).to eq('text/markdown') + end + end + + describe '.resource_list' do + it 'registers the sample resource' do + expect(described_class.resource_list.map(&:uri)).to contain_exactly('docs://SAMPLE_DOC.md') + end + end + + describe 'validations' do + it 'is valid for a known docs resource' do + controller = described_class.new('SAMPLE_DOC.md') + + expect(controller).to be_valid + end + + it 'is invalid for an unknown docs resource' do + controller = described_class.new('missing-doc') + + expect(controller).not_to be_valid + expect(controller.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', + { 'SAMPLE_DOC.md' => Rails.root.join('app/mcp/resources/docs/missing.md') }.freeze + ) + + controller = 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']) + end + end + + describe '#serve' do + it 'returns the markdown for the requested docs resource' do + controller = described_class.new('SAMPLE_DOC.md') + + content = controller.serve + + expect(content).to include('# Hello World') + end + end +end diff --git a/lib/generators/rolemodel/mcp/templates/spec/mcp/tools/sample_spec.rb b/lib/generators/rolemodel/mcp/templates/spec/mcp/tools/sample_spec.rb new file mode 100644 index 00000000..680a22af --- /dev/null +++ b/lib/generators/rolemodel/mcp/templates/spec/mcp/tools/sample_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Tools::Sample do + describe '.call' do + it 'returns a hash with expected fields' do + result = described_class.call(name: 'Alice', server_context: {}) + + expect(result).not_to be_error + + expect(result.structured_content).to have_key(:sample) + end + end +end diff --git a/lib/generators/rolemodel/mcp/templates/spec/requests/mcp_controller_spec.rb b/lib/generators/rolemodel/mcp/templates/spec/requests/mcp_controller_spec.rb new file mode 100644 index 00000000..c412155a --- /dev/null +++ b/lib/generators/rolemodel/mcp/templates/spec/requests/mcp_controller_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'MCPController', type: :request do + let(:user) { create(:user, name: 'Jane Smith', active: true, employee: true) } + let(:application) do + Doorkeeper::Application.create!( + name: 'MCP Test Client', + redirect_uri: 'https://example.com/callback', + scopes: 'mcp', + ) + end + + describe 'POST /mcp' do + let(:token) do + Doorkeeper::AccessToken.create!( + application: application, + resource_owner_id: user.id, + scopes: 'mcp', + expires_in: 2.hours.to_i, + ) + end + + let(:initialize_request) do + { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { + name: 'rspec', + version: '1.0', + }, + }, + } + end + + let(:headers) do + { + 'CONTENT_TYPE' => 'application/json', + 'ACCEPT' => 'application/json, text/event-stream', + } + end + + let(:authorized_headers) do + headers.merge('Authorization' => "Bearer #{token.token}") + end + + it 'returns unauthorized when no bearer token is provided' do + post '/mcp', params: initialize_request.to_json, headers: headers + + expect(response).to have_http_status(:unauthorized) + expect(response.headers['WWW-Authenticate']).to include('resource_metadata=') + end + + it 'returns forbidden when token does not include mcp scope' do + token = Doorkeeper::AccessToken.create!( + application: application, + resource_owner_id: user.id, + scopes: 'other', + expires_in: 2.hours.to_i, + ) + + post '/mcp', + params: initialize_request.to_json, + headers: headers.merge('Authorization' => "Bearer #{token.token}") + + expect(response).to have_http_status(:forbidden) + end + + it 'returns initialize response for a valid bearer token' do + post '/mcp', + params: initialize_request.to_json, + headers: authorized_headers + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['result']).to be_present + expect(response.parsed_body['result']['capabilities']).to include('tools') + end + end +end diff --git a/lib/generators/rolemodel/mcp/templates/spec/requests/oauth_registrations_controller_spec.rb b/lib/generators/rolemodel/mcp/templates/spec/requests/oauth_registrations_controller_spec.rb new file mode 100644 index 00000000..de58c9d8 --- /dev/null +++ b/lib/generators/rolemodel/mcp/templates/spec/requests/oauth_registrations_controller_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'OauthRegistrationsController', type: :request do + describe 'POST /oauth/register' do + let(:params) do + { + client_name: 'GitHub Copilot', + redirect_uris: ['http://localhost:12345/callback'], + grant_types: ['authorization_code'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + scope: 'mcp', + } + end + + it 'creates an OAuth application and returns client_id' do + expect { post '/oauth/register', params: params, as: :json } + .to change(Doorkeeper::Application, :count).by(1) + + expect(response).to have_http_status(:created) + body = response.parsed_body + expect(body['client_id']).to be_present + expect(body['client_name']).to eq('GitHub Copilot') + expect(body['redirect_uris']).to eq(['http://localhost:12345/callback']) + expect(body['token_endpoint_auth_method']).to eq('none') + expect(body).not_to have_key('client_secret') + + app = Doorkeeper::Application.last + expect(app.name).to eq('GitHub Copilot') + expect(app.redirect_uri).to eq('http://localhost:12345/callback') + expect(app.scopes).to contain_exactly('mcp') + expect(app).not_to be_confidential + end + + it 'returns bad_request when redirect_uris is missing' do + post '/oauth/register', params: { client_name: 'Test' }, as: :json + + expect(response).to have_http_status(:bad_request) + expect(response.parsed_body['error']).to eq('invalid_client_metadata') + end + + it 'creates a confidential client when token_endpoint_auth_method is not none' do + params[:token_endpoint_auth_method] = 'client_secret_basic' + post '/oauth/register', params: params, as: :json + + expect(response).to have_http_status(:created) + body = response.parsed_body + expect(body['client_secret']).to be_present + expect(body['token_endpoint_auth_method']).to eq('client_secret_basic') + end + + it 'allows loopback redirect URIs for native clients' do + params[:redirect_uris] = ['http://127.0.0.1:33418/'] + post '/oauth/register', params: params, as: :json + + expect(response).to have_http_status(:created) + expect(response.parsed_body['redirect_uris']).to eq(['http://127.0.0.1:33418/']) + end + end +end diff --git a/lib/generators/rolemodel/mcp/templates/spec/requests/well_known_controller_spec.rb b/lib/generators/rolemodel/mcp/templates/spec/requests/well_known_controller_spec.rb new file mode 100644 index 00000000..c7149c4f --- /dev/null +++ b/lib/generators/rolemodel/mcp/templates/spec/requests/well_known_controller_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'WellKnownController', type: :request do + describe 'GET /.well-known/oauth-protected-resource' do + it 'returns the MCP resource and authorization server' do + get '/.well-known/oauth-protected-resource' + + expect(response).to have_http_status(:ok) + body = response.parsed_body + expect(body['resource']).to end_with('/mcp') + expect(body['authorization_servers']).to be_an(Array) + end + end + + describe 'GET /.well-known/oauth-authorization-server' do + it 'returns authorization server metadata with registration_endpoint' do + get '/.well-known/oauth-authorization-server' + + expect(response).to have_http_status(:ok) + body = response.parsed_body + expect(body['registration_endpoint']).to end_with('/oauth/register') + expect(body['authorization_endpoint']).to end_with('/oauth/authorize') + expect(body['token_endpoint']).to end_with('/oauth/token') + expect(body['code_challenge_methods_supported']).to include('S256') + expect(body['scopes_supported']).to include('mcp') + end + end +end diff --git a/lib/generators/rolemodel/testing/rspec/templates/rails_helper.rb b/lib/generators/rolemodel/testing/rspec/templates/rails_helper.rb index 1555e24a..88b6230d 100644 --- a/lib/generators/rolemodel/testing/rspec/templates/rails_helper.rb +++ b/lib/generators/rolemodel/testing/rspec/templates/rails_helper.rb @@ -9,6 +9,10 @@ require 'rspec/rails' # Add additional requires below this line. Rails is not loaded until this point! +ActiveSupport::Inflector.inflections(:en) do |inflect| + inflect.acronym "MCP" +end + # Requires supporting ruby files with custom matchers and macros, etc, in # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are # run as spec files by default. This means that files in spec/support that end diff --git a/spec/generators/rolemodel/mcp_generator_spec.rb b/spec/generators/rolemodel/mcp_generator_spec.rb new file mode 100644 index 00000000..17890be0 --- /dev/null +++ b/spec/generators/rolemodel/mcp_generator_spec.rb @@ -0,0 +1,83 @@ +RSpec.describe Rolemodel::MCPGenerator, type: :generator do + before do + require_relative '../../tmp/config/application' # Needed for the mcp_controller template to get the application name + run_generator_against_test_app + end + + it 'adds the MCP controller' do + assert_file 'app/controllers/mcp_controller.rb' do |content| + 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 'config/routes.rb' do |content| + expect(content).to include("match '/mcp', to: 'mcp#handle', via: %i[get post delete]") + end + assert_file 'Gemfile' do |content| + expect(content).to include('gem "mcp"') + end + end + + 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/prompts/sample.rb' + 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 + assert_file 'Gemfile' do |content| + expect(content).to include('gem "doorkeeper"') + end + assert_file 'config/initializers/doorkeeper.rb' + assert_file 'config/locales/doorkeeper.en.yml' + assert_file 'app/views/layouts/doorkeeper.html.slim' + assert_file 'app/views/doorkeeper/authorizations/new.html.slim' do |content| + expect(content).to include('Example Rails Current - Authorize Application') + end + assert_file 'app/views/doorkeeper/authorizations/error.html.slim' do |content| + expect(content).to include('Example Rails Current - Error Authorizing Application') + end + assert_file 'app/controllers/doorkeeper/base_controller.rb' + assert_file 'app/assets/stylesheets/components/doorkeeper.css' + + assert_file 'config/routes.rb' do |content| + expect(content).to include('use_doorkeeper') + end + 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' + 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' + 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 + 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 +end