rolemodel-rails 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +15 -4
  3. data/lib/generators/rolemodel/all_generator.rb +2 -1
  4. data/lib/generators/rolemodel/editors/editors_generator.rb +1 -1
  5. data/lib/generators/rolemodel/github/github_generator.rb +1 -1
  6. data/lib/generators/rolemodel/good_job/good_job_generator.rb +1 -1
  7. data/lib/generators/rolemodel/heroku/heroku_generator.rb +1 -1
  8. data/lib/generators/rolemodel/kaminari/kaminari_generator.rb +1 -1
  9. data/lib/generators/rolemodel/linters/all_generator.rb +1 -1
  10. data/lib/generators/rolemodel/linters/eslint/eslint_generator.rb +1 -1
  11. data/lib/generators/rolemodel/linters/rubocop/rubocop_generator.rb +1 -1
  12. data/lib/generators/rolemodel/lograge/lograge_generator.rb +1 -1
  13. data/lib/generators/rolemodel/mailers/mailers_generator.rb +1 -1
  14. data/lib/generators/rolemodel/mcp/README.md +13 -0
  15. data/lib/generators/rolemodel/mcp/USAGE +8 -0
  16. data/lib/generators/rolemodel/mcp/mcp_generator.rb +110 -0
  17. data/lib/generators/rolemodel/mcp/templates/app/assets/stylesheets/components/doorkeeper.css +140 -0
  18. data/lib/generators/rolemodel/mcp/templates/app/controllers/doorkeeper/base_controller.rb +7 -0
  19. data/lib/generators/rolemodel/mcp/templates/app/controllers/mcp_controller.rb.tt +91 -0
  20. data/lib/generators/rolemodel/mcp/templates/app/controllers/oauth_registrations_controller.rb +46 -0
  21. data/lib/generators/rolemodel/mcp/templates/app/controllers/well_known_controller.rb +39 -0
  22. data/lib/generators/rolemodel/mcp/templates/app/mcp/prompts/sample.rb +36 -0
  23. data/lib/generators/rolemodel/mcp/templates/app/mcp/resources/controller.rb +57 -0
  24. data/lib/generators/rolemodel/mcp/templates/app/mcp/resources/docs/SAMPLE_DOC.md +4 -0
  25. data/lib/generators/rolemodel/mcp/templates/app/mcp/resources/docs_controller.rb +46 -0
  26. data/lib/generators/rolemodel/mcp/templates/app/mcp/tools/sample.rb +42 -0
  27. data/lib/generators/rolemodel/mcp/templates/app/views/doorkeeper/authorizations/error.html.slim.tt +13 -0
  28. data/lib/generators/rolemodel/mcp/templates/app/views/doorkeeper/authorizations/new.html.slim.tt +41 -0
  29. data/lib/generators/rolemodel/mcp/templates/app/views/layouts/doorkeeper.html.slim +7 -0
  30. data/lib/generators/rolemodel/mcp/templates/config/initializers/doorkeeper.rb +537 -0
  31. data/lib/generators/rolemodel/mcp/templates/spec/mcp/prompts/sample_spec.rb +15 -0
  32. data/lib/generators/rolemodel/mcp/templates/spec/mcp/resources/controller_spec.rb +16 -0
  33. data/lib/generators/rolemodel/mcp/templates/spec/mcp/resources/docs_controller_spec.rb +55 -0
  34. data/lib/generators/rolemodel/mcp/templates/spec/mcp/tools/sample_spec.rb +15 -0
  35. data/lib/generators/rolemodel/mcp/templates/spec/requests/mcp_controller_spec.rb +84 -0
  36. data/lib/generators/rolemodel/mcp/templates/spec/requests/oauth_registrations_controller_spec.rb +62 -0
  37. data/lib/generators/rolemodel/mcp/templates/spec/requests/well_known_controller_spec.rb +30 -0
  38. data/lib/generators/rolemodel/optics/all_generator.rb +1 -1
  39. data/lib/generators/rolemodel/optics/base/base_generator.rb +2 -2
  40. data/lib/generators/rolemodel/optics/icons/icons_generator.rb +1 -1
  41. data/lib/generators/rolemodel/react/react_generator.rb +1 -1
  42. data/lib/generators/rolemodel/readme/readme_generator.rb +1 -1
  43. data/lib/generators/rolemodel/saas/all_generator.rb +1 -1
  44. data/lib/generators/rolemodel/saas/devise/devise_generator.rb +1 -1
  45. data/lib/generators/rolemodel/semaphore/semaphore_generator.rb +1 -1
  46. data/lib/generators/rolemodel/simple_form/simple_form_generator.rb +1 -1
  47. data/lib/generators/rolemodel/slim/slim_generator.rb +1 -1
  48. data/lib/generators/rolemodel/soft_destroyable/soft_destroyable_generator.rb +1 -1
  49. data/lib/generators/rolemodel/source_map/source_map_generator.rb +1 -1
  50. data/lib/generators/rolemodel/tailored_select/tailored_select_generator.rb +1 -1
  51. data/lib/generators/rolemodel/testing/all_generator.rb +1 -1
  52. data/lib/generators/rolemodel/testing/factory_bot/factory_bot_generator.rb +1 -1
  53. data/lib/generators/rolemodel/testing/jasmine_playwright/jasmine_playwright_generator.rb +1 -1
  54. data/lib/generators/rolemodel/testing/parallel_tests/parallel_tests_generator.rb +1 -1
  55. data/lib/generators/rolemodel/testing/rspec/rspec_generator.rb +1 -2
  56. data/lib/generators/rolemodel/testing/rspec/templates/rails_helper.rb +4 -0
  57. data/lib/generators/rolemodel/testing/rspec/templates/support/capybara_drivers.rb +0 -2
  58. data/lib/generators/rolemodel/testing/test_prof/test_prof_generator.rb +1 -1
  59. data/lib/generators/rolemodel/testing/vitest/vitest_generator.rb +1 -1
  60. data/lib/generators/rolemodel/ui_components/all_generator.rb +1 -1
  61. data/lib/generators/rolemodel/ui_components/flash/flash_generator.rb +1 -1
  62. data/lib/generators/rolemodel/ui_components/modals/modals_generator.rb +1 -1
  63. data/lib/generators/rolemodel/ui_components/navbar/navbar_generator.rb +1 -1
  64. data/lib/generators/rolemodel/webpack/webpack_generator.rb +1 -1
  65. data/lib/rolemodel/engine.rb +3 -1
  66. data/lib/rolemodel/generator_base.rb +17 -0
  67. data/lib/rolemodel/version.rb +1 -1
  68. metadata +27 -7
  69. data/lib/generators/rolemodel/base_generator.rb +0 -16
  70. data/lib/generators/templates/generator/%filename%.rb.tt +0 -11
  71. data/lib/generators/templates/generator/README.md.tt +0 -11
  72. data/lib/generators/templates/generator/USAGE.tt +0 -5
  73. data/lib/generators/templates/generator_spec/%filename%_spec.rb.tt +0 -5
  74. /data/lib/{generators/rolemodel → rolemodel}/replace_content_helper.rb +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b0fc94b9f1782c5251c05b2ec1e9f8de40ff8021383c4d358c1cf67dae0c2e86
4
- data.tar.gz: 8eb5debdc363920c4b21f06381b4c6789162a5dcd984c40ba377fecfb1eb90a2
3
+ metadata.gz: d3e11cf41b7d9e5afb68a8eaca61a30785b10dcb9032fc327a52f40ea8271a2e
4
+ data.tar.gz: 4b4388ba6e3b2608135be47000bc9ba655b32bd4936e096d2e3ba8cafe4904c3
5
5
  SHA512:
6
- metadata.gz: be60dc3620a2d22b8ed1148a1966219c830d685e48df12ed5abafd884cd1f749f4251360173f42b355b10865238f000a95da825624c21ee898fde25c7c9dc9d9
7
- data.tar.gz: b76c9375e38755a22e66aff75109727a7a03273d9ee4dc9e44049774259f2b60ca793f3a550b79550cdd729cca9154e400547a2472f2565fe18fedb05cfd10e3
6
+ metadata.gz: 173f38c6b5f5f0101a143aaf8a5301afd7a69295d68e1c8eb54a5bd2cf06b57254f658e312a19a3a2605f6920ddab05f6f8941a0797ce7400e83c2e3ff13ac38
7
+ data.tar.gz: 77b6be040efd20cd738c14c44c92c57ac84241a75685cc157bfa86ddc3b6b7b6675eec3b2ad8a3b8c6dfb83c7e568aca7420746b6b81ef7d1172d6392c490c47
data/README.md CHANGED
@@ -28,9 +28,7 @@ rails db:create
28
28
  Add this line to your application's Gemfile:
29
29
 
30
30
  ```ruby
31
- group :development do
32
- gem 'rolemodel-rails', github: 'RoleModel/rolemodel_rails'
33
- end
31
+ gem 'rolemodel-rails', group: :development
34
32
  ```
35
33
 
36
34
  And then execute:
@@ -115,7 +113,7 @@ e.g.
115
113
  bin/new_generator testing/fantasitic_specs 'A Fantastic Testing Framework'
116
114
  ```
117
115
 
118
- We use the embeded Rails apps (`example_rails*`) to test generators against. They reference the rolemodel-rails gem by local path,
116
+ We use the embeded Rails apps (`example_rails_current` & `example_rails_legacy`) to test generators against. They reference the rolemodel-rails gem by local path,
119
117
  so you can navigate into one of them and run your generator for immediate feedback while developing.
120
118
 
121
119
  > [!IMPORTANT]
@@ -146,6 +144,19 @@ RSpec.describe Rolemodel::Testing::JasminePlaywrightGenerator, type: :generator
146
144
  end
147
145
  ```
148
146
 
147
+ If the generator you're testing depends on being run after another generator, you should run that one first.
148
+
149
+ e.g.
150
+
151
+ ```ruby
152
+ RSpec.describe Rolemodel::MyGenerator, type: :generator do
153
+ before do
154
+ run_generator_against_test_app(generator: ::Rolemodel::PrereqGenerator)
155
+ run_generator_against_test_app
156
+ end
157
+ end
158
+ ```
159
+
149
160
  Additional information about testing generators and the available assertions & matchers can be found at the following resources.
150
161
 
151
162
  * [Rails Guide](https://guides.rubyonrails.org/generators.html#testing-generators)
@@ -1,5 +1,5 @@
1
1
  module Rolemodel
2
- class AllGenerator < BaseGenerator
2
+ class AllGenerator < GeneratorBase
3
3
  source_root File.expand_path('templates', __dir__)
4
4
 
5
5
  def run_all_the_generators
@@ -24,6 +24,7 @@ module Rolemodel
24
24
  generate 'rolemodel:editors'
25
25
  # generate 'rolemodel:tailored_select' # Not production ready
26
26
  generate 'rolemodel:lograge'
27
+ generate 'rolemodel:mcp'
27
28
  end
28
29
  end
29
30
  end
@@ -4,7 +4,7 @@ require_relative 'vscode_helpers'
4
4
 
5
5
  module Rolemodel
6
6
  # Add standard editorconfig and any extensions to enable it
7
- class EditorsGenerator < BaseGenerator
7
+ class EditorsGenerator < GeneratorBase
8
8
  include Rolemodel::VSCodeHelpers
9
9
 
10
10
  # This is bringing in the root from this gem, so we only modify
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rolemodel
4
- class GithubGenerator < BaseGenerator
4
+ class GithubGenerator < GeneratorBase
5
5
  GITHUB_ACTIONS_REPO = 'https://github.com/RoleModel/actions.git'
6
6
  # Files which are both used by the gem source and copied to the target app without modification
7
7
  # are placed in the `.github` folder at the top level of this repository. This folder is then
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rolemodel
4
- class GoodJobGenerator < BaseGenerator
4
+ class GoodJobGenerator < GeneratorBase
5
5
  source_root File.expand_path('templates', __dir__)
6
6
 
7
7
  def install_good_job
@@ -1,5 +1,5 @@
1
1
  module Rolemodel
2
- class HerokuGenerator < BaseGenerator
2
+ class HerokuGenerator < GeneratorBase
3
3
  source_root File.expand_path('templates', __dir__)
4
4
 
5
5
  def install_app_json
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rolemodel
4
- class KaminariGenerator < BaseGenerator
4
+ class KaminariGenerator < GeneratorBase
5
5
  source_root File.expand_path('templates', __dir__)
6
6
 
7
7
  def install_kaminari
@@ -1,6 +1,6 @@
1
1
  module Rolemodel
2
2
  module Linters
3
- class AllGenerator < BaseGenerator
3
+ class AllGenerator < GeneratorBase
4
4
  source_root File.expand_path('templates', __dir__)
5
5
 
6
6
  def run_all_the_generators
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Rolemodel
4
4
  module Linters
5
- class EslintGenerator < BaseGenerator
5
+ class EslintGenerator < GeneratorBase
6
6
  include ReplaceContentHelper
7
7
  source_root File.expand_path('templates', __dir__)
8
8
 
@@ -3,7 +3,7 @@
3
3
  module Rolemodel
4
4
  module Linters
5
5
  # Install the standard rubocop and a custom cop
6
- class RubocopGenerator < BaseGenerator
6
+ class RubocopGenerator < GeneratorBase
7
7
  source_root File.expand_path('templates', __dir__)
8
8
 
9
9
  def install_rubocop
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rolemodel
4
- class LogrageGenerator < BaseGenerator
4
+ class LogrageGenerator < GeneratorBase
5
5
  source_root File.expand_path('templates', __dir__)
6
6
 
7
7
  def install_lograge
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rolemodel
4
- class MailersGenerator < BaseGenerator
4
+ class MailersGenerator < GeneratorBase
5
5
  source_root File.expand_path('templates', __dir__)
6
6
 
7
7
  def install_premailer_rails
@@ -0,0 +1,13 @@
1
+ # MCP Generator
2
+
3
+ Install boilerplate for your very own MCP server.
4
+
5
+ ## What you get
6
+
7
+ ### Doorkeeper
8
+
9
+ OAuth 2.1-enabled flow with dynamic application registration.
10
+
11
+ ### MCP
12
+
13
+ A basic MCP controller that you can build on to serve tools, resources, and prompts.
@@ -0,0 +1,8 @@
1
+ Description:
2
+ Sets up RoleModel MCP support and any required application wiring for the MCP endpoint.
3
+
4
+ Example:
5
+ rails generate rolemodel:mcp
6
+
7
+ This generator adds the files and route updates needed to enable RoleModel MCP
8
+ in your Rails application.
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rolemodel
4
+ class McpGenerator < GeneratorBase
5
+ source_root File.expand_path('templates', __dir__)
6
+
7
+ def update_inflections
8
+ inflections_path = File.join(destination_root, 'config/initializers/inflections.rb')
9
+ block_start = "\nActiveSupport::Inflector.inflections(:en) do |inflect|\n"
10
+
11
+ return if File.read(inflections_path).include?("inflect.acronym 'MCP'")
12
+
13
+ if File.read(inflections_path).include?(block_start)
14
+ inject_into_file inflections_path, " inflect.acronym 'MCP'\n", after: block_start
15
+ else
16
+ append_to_file inflections_path, <<~RUBY
17
+
18
+ ActiveSupport::Inflector.inflections(:en) do |inflect|
19
+ inflect.acronym 'MCP'
20
+ end
21
+ RUBY
22
+ end
23
+ end
24
+
25
+ def install_mcp
26
+ bundle_command 'add mcp'
27
+ template 'app/controllers/mcp_controller.rb'
28
+ copy_file 'spec/requests/mcp_controller_spec.rb'
29
+
30
+ route <<~RUBY
31
+ match '/mcp', to: 'mcp#handle', via: %i[get post delete]
32
+ RUBY
33
+ end
34
+
35
+ def add_sample_mcp_resource
36
+ copy_file 'app/mcp/resources/controller.rb'
37
+ copy_file 'spec/mcp/resources/controller_spec.rb'
38
+
39
+ copy_file 'app/mcp/resources/docs/SAMPLE_DOC.md'
40
+ copy_file 'app/mcp/resources/docs_controller.rb'
41
+ copy_file 'spec/mcp/resources/docs_controller_spec.rb'
42
+ end
43
+
44
+ def add_sample_mcp_prompt
45
+ copy_file 'app/mcp/prompts/sample.rb'
46
+ copy_file 'spec/mcp/prompts/sample_spec.rb'
47
+ end
48
+
49
+ def add_sample_mcp_tool
50
+ copy_file 'app/mcp/tools/sample.rb'
51
+ copy_file 'spec/mcp/tools/sample_spec.rb'
52
+ end
53
+
54
+ def install_doorkeeper
55
+ bundle_command 'add doorkeeper'
56
+ run_bundle
57
+ generate 'doorkeeper:install'
58
+ end
59
+
60
+ def configure_doorkeeper
61
+ copy_file 'config/initializers/doorkeeper.rb', force: true
62
+ copy_file 'app/controllers/doorkeeper/base_controller.rb'
63
+
64
+ copy_file 'app/views/layouts/doorkeeper.html.slim'
65
+ template 'app/views/doorkeeper/authorizations/new.html.slim'
66
+ template 'app/views/doorkeeper/authorizations/error.html.slim'
67
+
68
+ copy_file 'app/assets/stylesheets/components/doorkeeper.css'
69
+
70
+ route 'use_doorkeeper'
71
+ end
72
+
73
+ def apply_doorkeeper_css
74
+ css_manifest = if File.exist?(File.join(destination_root, 'app/assets/stylesheets/application.scss'))
75
+ 'app/assets/stylesheets/application.scss'
76
+ else
77
+ 'app/assets/stylesheets/application.css'
78
+ end
79
+
80
+ return if File.read(File.join(destination_root, css_manifest)).include?("@import 'components/doorkeeper.css';")
81
+
82
+ append_to_file css_manifest, <<~CSS
83
+ @import 'components/doorkeeper.css';
84
+ CSS
85
+ end
86
+
87
+ def add_oauth_dynamic_registrations
88
+ copy_file 'app/controllers/oauth_registrations_controller.rb'
89
+ copy_file 'spec/requests/oauth_registrations_controller_spec.rb'
90
+ route <<~RUBY
91
+ post '/oauth/register', to: 'oauth_registrations#create'
92
+ RUBY
93
+ end
94
+
95
+ def add_well_known_route
96
+ copy_file 'app/controllers/well_known_controller.rb'
97
+ copy_file 'spec/requests/well_known_controller_spec.rb'
98
+ route <<~RUBY
99
+ get '/.well-known/oauth-protected-resource', to: 'well_known#oauth_protected_resource'
100
+ get '/.well-known/oauth-authorization-server', to: 'well_known#oauth_authorization_server'
101
+ RUBY
102
+ end
103
+
104
+ private
105
+
106
+ def application_name
107
+ Rails.application.name
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,140 @@
1
+ .doorkeeper {
2
+ position: fixed;
3
+ inset: 0;
4
+ box-sizing: border-box;
5
+ display: flex;
6
+ align-items: center;
7
+ justify-content: center;
8
+ padding: var(--op-space-x-large) var(--op-space-large);
9
+ overflow: auto;
10
+ background-color: var(--op-color-neutral-plus-eight);
11
+ color: var(--op-color-neutral-minus-max);
12
+ font-family: var(--op-font-family);
13
+ }
14
+
15
+ .doorkeeper__card {
16
+ width: min(100%, 48rem);
17
+ background-color: var(--op-color-white);
18
+ border: var(--op-border-width) solid var(--op-color-neutral-plus-six);
19
+ border-radius: var(--op-radius-x-large);
20
+ box-shadow: var(--op-shadow-large);
21
+ display: flex;
22
+ flex-direction: column;
23
+ gap: var(--op-space-large);
24
+ padding: var(--op-space-2x-large);
25
+ }
26
+
27
+ .doorkeeper__brand {
28
+ display: flex;
29
+ flex-direction: column;
30
+ align-items: center;
31
+ gap: var(--op-space-large);
32
+ margin-bottom: var(--op-space-small);
33
+ }
34
+
35
+ .doorkeeper__logo {
36
+ width: 180px;
37
+ height: auto;
38
+ }
39
+
40
+ .doorkeeper__title {
41
+ margin: 0;
42
+ font-size: var(--op-font-2x-large);
43
+ font-weight: var(--op-font-weight-bold);
44
+ color: var(--op-color-primary-minus-two);
45
+ text-align: center;
46
+ letter-spacing: -0.04em;
47
+ }
48
+
49
+ .doorkeeper__prompt {
50
+ margin: 0;
51
+ font-size: var(--op-font-large);
52
+ line-height: var(--op-line-height-loose);
53
+ color: var(--op-color-neutral-minus-three);
54
+ text-align: center;
55
+ }
56
+
57
+ .doorkeeper__client-name {
58
+ color: var(--op-color-primary-base);
59
+ font-weight: var(--op-font-weight-bold);
60
+ }
61
+
62
+ .doorkeeper__permissions {
63
+ background-color: var(--op-color-primary-plus-eight);
64
+ border: var(--op-border-width) solid var(--op-color-primary-plus-six);
65
+ border-radius: var(--op-radius-medium);
66
+ padding: var(--op-space-large);
67
+ display: flex;
68
+ flex-direction: column;
69
+ gap: var(--op-space-medium);
70
+ }
71
+
72
+ .doorkeeper__permissions-label {
73
+ margin: 0;
74
+ font-size: var(--op-font-small);
75
+ text-transform: uppercase;
76
+ letter-spacing: 0.05em;
77
+ font-weight: var(--op-font-weight-bold);
78
+ color: var(--op-color-primary-minus-three);
79
+ }
80
+
81
+ .doorkeeper__scope-list {
82
+ margin: 0;
83
+ padding-left: var(--op-space-large);
84
+ display: grid;
85
+ gap: var(--op-space-small);
86
+ color: var(--op-color-primary-minus-max);
87
+ }
88
+
89
+ .doorkeeper__scope-item {
90
+ line-height: var(--op-line-height-base);
91
+ font-weight: var(--op-font-weight-medium);
92
+ }
93
+
94
+ .doorkeeper__actions {
95
+ display: flex;
96
+ flex-direction: column;
97
+ gap: var(--op-space-medium);
98
+ margin-top: var(--op-space-medium);
99
+ }
100
+
101
+ .doorkeeper__error {
102
+ align-self: stretch;
103
+ }
104
+
105
+ .doorkeeper__error-description {
106
+ margin: 0;
107
+ font-size: var(--op-font-medium);
108
+ line-height: var(--op-line-height-base);
109
+ white-space: pre-wrap;
110
+ overflow-wrap: anywhere;
111
+ }
112
+
113
+ .doorkeeper__form {
114
+ margin: 0;
115
+ }
116
+
117
+ .doorkeeper__button {
118
+ width: 100%;
119
+ justify-content: center;
120
+ font-weight: var(--op-font-weight-semi-bold);
121
+ }
122
+
123
+ @media (max-width: 640px) {
124
+ .doorkeeper {
125
+ padding: var(--op-space-large) var(--op-space-medium);
126
+ }
127
+
128
+ .doorkeeper__card {
129
+ padding: var(--op-space-large);
130
+ border-radius: var(--op-radius-large);
131
+ }
132
+
133
+ .doorkeeper__logo {
134
+ width: 140px;
135
+ }
136
+
137
+ .doorkeeper__title {
138
+ font-size: var(--op-font-x-large);
139
+ }
140
+ }
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doorkeeper
4
+ class BaseController < ::ApplicationController
5
+ skip_forgery_protection
6
+ end
7
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ class MCPController < ApplicationController
4
+ # skip_before_action :authenticate_user!
5
+ skip_forgery_protection
6
+
7
+ before_action :authorize_mcp
8
+ before_action :set_current_user
9
+
10
+ def handle
11
+ server = build_server
12
+ transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
13
+ server.transport = transport
14
+
15
+ status, response_headers, body = transport.handle_request(request)
16
+ respond_with(status, response_headers, body)
17
+ end
18
+
19
+ private
20
+
21
+ def respond_with(status, headers, body)
22
+ headers.each { |key, value| response.set_header(key, value) }
23
+ response.status = status
24
+ self.response_body = body
25
+ end
26
+
27
+ def authorize_mcp
28
+ doorkeeper_authorize! :mcp
29
+ set_mcp_resource_metadata_header if response.status == 401
30
+ end
31
+
32
+ def set_current_user
33
+ @current_user = User.find_by(id: doorkeeper_token&.resource_owner_id)
34
+ unauthorized_request if @current_user.blank?
35
+ end
36
+
37
+ def unauthorized_request
38
+ set_mcp_resource_metadata_header
39
+ render json: { error: 'Unauthorized' }, status: :unauthorized
40
+ end
41
+
42
+ def set_mcp_resource_metadata_header
43
+ metadata = %(resource_metadata="#{request.base_url}/.well-known/oauth-protected-resource")
44
+ response.set_header('WWW-Authenticate', %(Bearer realm="<%= application_name.titleize %>", #{metadata}))
45
+ end
46
+
47
+ def build_server
48
+ server = MCP::Server.new(**mcp_server_config)
49
+ handle_resources(server)
50
+
51
+ server
52
+ end
53
+
54
+ def handle_resources(server) # rubocop:disable Metrics/MethodLength
55
+ controllers = [
56
+ Resources::DocsController
57
+ ]
58
+
59
+ server.resources_read_handler do |params|
60
+ uri = params[:uri].to_s
61
+ controller = controllers.find { |h| h.serves?(uri) }
62
+
63
+ unless controller
64
+ raise MCP::Server::RequestHandlerError.new(
65
+ "Unable to serve resource for URI: #{uri}. Supported schemas: #{controllers.map(&:schema).join(', ')}",
66
+ params,
67
+ error_type: :invalid_params
68
+ )
69
+ end
70
+
71
+ controller.call(params, server_context)
72
+ end
73
+ end
74
+
75
+ def mcp_server_config # rubocop:disable Metrics/MethodLength
76
+ {
77
+ name: '<%= application_name.underscore %>_mcp',
78
+ version: '1.0.0',
79
+ tools: [Tools::Sample],
80
+ prompts: [Prompts::Sample],
81
+ server_context:,
82
+ resources: [
83
+ *Resources::DocsController.resource_list,
84
+ ],
85
+ }
86
+ end
87
+
88
+ def server_context
89
+ @server_context ||= { current_user: @current_user }
90
+ end
91
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ class OauthRegistrationsController < ApplicationController
4
+ # skip_before_action :authenticate_user!
5
+ skip_forgery_protection
6
+
7
+ def create
8
+ app = Doorkeeper::Application.new(doorkeeper_params)
9
+ return client_metadata_error('redirect_uris is required') if app.redirect_uri.blank?
10
+
11
+ if app.save
12
+ render json: base_response(app), status: :created
13
+ else
14
+ client_metadata_error(app.errors.full_messages.join(', '))
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def base_response(app)
21
+ {
22
+ client_id: app.uid,
23
+ client_name: app.name,
24
+ redirect_uris: app.redirect_uri.split("\n"),
25
+ grant_types: %w[authorization_code refresh_token],
26
+ response_types: ['code'],
27
+ token_endpoint_auth_method: app.confidential? ? 'client_secret_basic' : 'none',
28
+ client_id_issued_at: app.created_at.to_i,
29
+ scope: 'mcp',
30
+ client_secret: app.confidential? ? app.secret : nil,
31
+ }.compact
32
+ end
33
+
34
+ def client_metadata_error(description)
35
+ render json: { error: 'invalid_client_metadata', error_description: description }, status: :bad_request
36
+ end
37
+
38
+ def doorkeeper_params
39
+ {
40
+ name: params[:client_name].presence || 'MCP Client',
41
+ redirect_uri: params[:redirect_uris].is_a?(Array) ? params[:redirect_uris].join("\n") : params[:redirect_uris],
42
+ scopes: 'mcp',
43
+ confidential: params[:token_endpoint_auth_method] != 'none',
44
+ }
45
+ end
46
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ class WellKnownController < ApplicationController
4
+ # skip_before_action :authenticate_user!
5
+ before_action :set_base_url
6
+
7
+ def oauth_protected_resource
8
+ render json: {
9
+ resource: "#{@base_url}/mcp",
10
+ authorization_servers: [@base_url],
11
+ }
12
+ end
13
+
14
+ def oauth_authorization_server
15
+ render json: authorization_server_metadata
16
+ end
17
+
18
+ private
19
+
20
+ def authorization_server_metadata # rubocop:disable Metrics/MethodLength
21
+ {
22
+ issuer: @base_url,
23
+ authorization_endpoint: "#{@base_url}/oauth/authorize",
24
+ token_endpoint: "#{@base_url}/oauth/token",
25
+ registration_endpoint: "#{@base_url}/oauth/register",
26
+ revocation_endpoint: "#{@base_url}/oauth/revoke",
27
+ introspection_endpoint: "#{@base_url}/oauth/introspect",
28
+ scopes_supported: ['mcp'],
29
+ response_types_supported: ['code'],
30
+ grant_types_supported: %w[authorization_code client_credentials refresh_token],
31
+ token_endpoint_auth_methods_supported: %w[none client_secret_basic client_secret_post],
32
+ code_challenge_methods_supported: ['S256'],
33
+ }
34
+ end
35
+
36
+ def set_base_url
37
+ @base_url = request.base_url
38
+ end
39
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prompts
4
+ class Sample < ::MCP::Prompt
5
+ prompt_name 'sample_prompt'
6
+ title 'Sample Prompt'
7
+ description 'Sample prompt description'
8
+
9
+ class << self
10
+ def template(_args, _server_context: nil)
11
+ ::MCP::Prompt::Result.new(
12
+ description: 'Sample prompt result description',
13
+ messages: [
14
+ ::MCP::Prompt::Message.new(
15
+ role: 'assistant',
16
+ content: ::MCP::Content::Text.new(instructions_text),
17
+ ),
18
+ ],
19
+ )
20
+ end
21
+
22
+ private
23
+
24
+ def instructions_text
25
+ <<~TEXT
26
+ This is a sample prompt.
27
+
28
+ MCP prompts can return instructions for the agent, which can be used to guide the agent's behavior.
29
+ For example, you might include instructions on how to query a specific resource or use a specific tool.
30
+ Think of it like a system prompt in a conversational agent, but it can be dynamically generated based on the
31
+ context of the request.
32
+ TEXT
33
+ end
34
+ end
35
+ end
36
+ end