mcp_on_ruby 1.0.0 → 2.0.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -3
  3. data/README.md +359 -163
  4. data/config/routes.rb +5 -0
  5. data/lib/generators/mcp_on_ruby/expose/expose_generator.rb +108 -0
  6. data/lib/generators/mcp_on_ruby/install/install_generator.rb +37 -0
  7. data/lib/generators/mcp_on_ruby/install/templates/initializer.rb +22 -0
  8. data/lib/mcp_on_ruby/call_context.rb +29 -0
  9. data/lib/mcp_on_ruby/configuration.rb +63 -59
  10. data/lib/mcp_on_ruby/controller.rb +60 -0
  11. data/lib/mcp_on_ruby/declaration.rb +24 -0
  12. data/lib/mcp_on_ruby/dispatcher.rb +97 -0
  13. data/lib/mcp_on_ruby/doctor.rb +67 -0
  14. data/lib/mcp_on_ruby/endpoint.rb +43 -0
  15. data/lib/mcp_on_ruby/engine.rb +29 -0
  16. data/lib/mcp_on_ruby/registry.rb +146 -0
  17. data/lib/mcp_on_ruby/result.rb +83 -0
  18. data/lib/mcp_on_ruby/rspec.rb +8 -0
  19. data/lib/mcp_on_ruby/schema.rb +149 -0
  20. data/lib/mcp_on_ruby/server_factory.rb +66 -0
  21. data/lib/mcp_on_ruby/stdio.rb +42 -0
  22. data/lib/mcp_on_ruby/test_helper.rb +86 -0
  23. data/lib/mcp_on_ruby/tool_spec.rb +87 -0
  24. data/lib/mcp_on_ruby/tool_table.rb +31 -0
  25. data/lib/mcp_on_ruby/version.rb +2 -3
  26. data/lib/mcp_on_ruby/warden_hook.rb +21 -0
  27. data/lib/mcp_on_ruby.rb +44 -122
  28. data/lib/tasks/mcp_on_ruby.rake +20 -0
  29. metadata +65 -136
  30. data/CODE_OF_CONDUCT.md +0 -56
  31. data/CONTRIBUTING.md +0 -86
  32. data/bin/console +0 -11
  33. data/bin/setup +0 -6
  34. data/docs/advanced-usage.md +0 -132
  35. data/docs/api-reference.md +0 -35
  36. data/docs/testing.md +0 -55
  37. data/examples/claude/README.md +0 -171
  38. data/examples/claude/claude-bridge.js +0 -122
  39. data/lib/mcp_on_ruby/errors.rb +0 -137
  40. data/lib/mcp_on_ruby/generators/install_generator.rb +0 -46
  41. data/lib/mcp_on_ruby/generators/resource_generator.rb +0 -63
  42. data/lib/mcp_on_ruby/generators/templates/README +0 -31
  43. data/lib/mcp_on_ruby/generators/templates/application_resource.rb +0 -20
  44. data/lib/mcp_on_ruby/generators/templates/application_tool.rb +0 -18
  45. data/lib/mcp_on_ruby/generators/templates/initializer.rb +0 -41
  46. data/lib/mcp_on_ruby/generators/templates/resource.rb +0 -50
  47. data/lib/mcp_on_ruby/generators/templates/resource_spec.rb +0 -67
  48. data/lib/mcp_on_ruby/generators/templates/sample_resource.rb +0 -57
  49. data/lib/mcp_on_ruby/generators/templates/sample_tool.rb +0 -59
  50. data/lib/mcp_on_ruby/generators/templates/tool.rb +0 -38
  51. data/lib/mcp_on_ruby/generators/templates/tool_spec.rb +0 -55
  52. data/lib/mcp_on_ruby/generators/tool_generator.rb +0 -51
  53. data/lib/mcp_on_ruby/railtie.rb +0 -108
  54. data/lib/mcp_on_ruby/resource.rb +0 -161
  55. data/lib/mcp_on_ruby/server.rb +0 -378
  56. data/lib/mcp_on_ruby/tool.rb +0 -134
  57. data/lib/mcp_on_ruby/transport.rb +0 -330
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module McpOnRuby
6
+ module Generators
7
+ # rails generate mcp_on_ruby:expose orders index show
8
+ class ExposeGenerator < Rails::Generators::Base
9
+ WRITE_ACTIONS = %w[create update destroy].freeze
10
+ PARAM_ACTIONS = %w[create update].freeze
11
+ SKIPPED_COLUMNS = %w[created_at updated_at].freeze
12
+
13
+ COLUMN_TYPES = {
14
+ integer: "type: :integer", bigint: "type: :integer",
15
+ decimal: "type: :number", float: "type: :number",
16
+ boolean: "type: :boolean",
17
+ date: 'type: :string, format: "date"',
18
+ datetime: 'type: :string, format: "date-time"', timestamp: 'type: :string, format: "date-time"',
19
+ json: "type: :object", jsonb: "type: :object"
20
+ }.freeze
21
+
22
+ desc "Adds mcp_tool declarations for existing controller actions."
23
+
24
+ argument :controller, type: :string, banner: "CONTROLLER", desc: "Controller path, e.g. orders or admin/orders"
25
+ argument :actions, type: :array, banner: "ACTION ACTION", desc: "Actions to expose"
26
+
27
+ def add_declarations
28
+ unless File.exist?(File.join(destination_root, controller_file))
29
+ raise Thor::Error, "#{controller_file} does not exist"
30
+ end
31
+
32
+ pending = actions.reject { |action| declared?(action) }
33
+ return say_status(:identical, controller_file, :blue) if pending.empty?
34
+
35
+ inject_into_file controller_file, declarations(pending), after: class_line_pattern
36
+ end
37
+
38
+ private
39
+
40
+ def declarations(pending)
41
+ indent = "#{class_line[:indent]} "
42
+ text = pending.map { |action| declaration(action).gsub(/^/, indent) }.join
43
+ following_line = source[class_line.end(0)..].lines.first.to_s.strip
44
+ following_line.empty? || following_line == "end" ? text : "#{text}\n"
45
+ end
46
+
47
+ def controller_file
48
+ "app/controllers/#{controller.underscore.delete_suffix("_controller")}_controller.rb"
49
+ end
50
+
51
+ def controller_class
52
+ "#{controller.underscore.delete_suffix("_controller").camelize}Controller"
53
+ end
54
+
55
+ # Matches `class ReportsController` as well as `class Admin::ReportsController`.
56
+ def class_line_pattern
57
+ /^(?<indent>[ \t]*)class (?:\w+::)*#{controller_class.demodulize}\b.*\n/
58
+ end
59
+
60
+ def class_line
61
+ @class_line ||= class_line_pattern.match(source) ||
62
+ raise(Thor::Error, "#{controller_class} not found in #{controller_file}")
63
+ end
64
+
65
+ def source
66
+ File.read(File.join(destination_root, controller_file))
67
+ end
68
+
69
+ def declared?(action)
70
+ source.match?(/mcp_tool\s+:#{action}\b/)
71
+ end
72
+
73
+ def declaration(action)
74
+ line = %(mcp_tool :#{action}, description: "#{description(action)}")
75
+ line += ", write: true" if WRITE_ACTIONS.include?(action)
76
+ params = PARAM_ACTIONS.include?(action) ? model_params : nil
77
+ params ? "#{line} do\n#{params}end\n" : "#{line}\n"
78
+ end
79
+
80
+ def description(action)
81
+ resource = controller.split("/").last.humanize(capitalize: false)
82
+ record = resource.singularize
83
+ article = record.match?(/\A[aeiou]/i) ? "an" : "a"
84
+ {
85
+ "index" => "List #{resource}",
86
+ "show" => "Show one #{record} by id",
87
+ "create" => "Create #{article} #{record}",
88
+ "update" => "Update #{article} #{record}",
89
+ "destroy" => "Delete #{article} #{record}"
90
+ }.fetch(action) { "TODO: describe what #{action} does for the agent" }
91
+ end
92
+
93
+ # A params block built from the model's columns, when the controller has an ActiveRecord model.
94
+ def model_params
95
+ model = controller.split("/").last.classify.safe_constantize
96
+ return unless model.respond_to?(:columns_hash)
97
+
98
+ columns = model.columns_hash.except(model.primary_key, *SKIPPED_COLUMNS)
99
+ return if columns.empty?
100
+
101
+ lines = columns.map do |name, column|
102
+ " param :#{name}, #{COLUMN_TYPES.fetch(column.type, "type: :string")}\n"
103
+ end
104
+ " param :#{model.model_name.param_key}, required: true do\n#{lines.join} end\n"
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module McpOnRuby
6
+ module Generators
7
+ # rails generate mcp_on_ruby:install
8
+ class InstallGenerator < Rails::Generators::Base
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ desc "Adds the MCP on Ruby initializer and mounts the MCP endpoint at /mcp."
12
+
13
+ class_option :path, type: :string, default: "/mcp", desc: "Where to mount the MCP endpoint"
14
+
15
+ def create_initializer
16
+ template "initializer.rb", "config/initializers/mcp_on_ruby.rb"
17
+ end
18
+
19
+ def mount_engine
20
+ route %(mount McpOnRuby::Engine, at: "#{options[:path]}")
21
+ end
22
+
23
+ def show_next_steps
24
+ say <<~MESSAGE
25
+
26
+ MCP on Ruby is installed. Next:
27
+
28
+ 1. Expose actions: bin/rails generate mcp_on_ruby:expose orders index show
29
+ 2. Review them: bin/rails mcp:tools
30
+ 3. Check the setup: bin/rails mcp:doctor
31
+ 4. Connect an agent: claude mcp add my-app -- bin/rails mcp:stdio
32
+
33
+ MESSAGE
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ McpOnRuby.configure do |config|
4
+ # Name and version reported to MCP clients.
5
+ # config.server_name = "<%= Rails.application.class.module_parent_name.underscore.dasherize %>"
6
+ # config.server_version = "1.0.0"
7
+
8
+ # Guidance sent to agents when they connect.
9
+ # config.instructions = "Tools for <%= Rails.application.class.module_parent_name.titleize %>."
10
+
11
+ # Identity for local sessions started with `bin/rails mcp:stdio`.
12
+ # Devise / Warden apps: sign in as a user.
13
+ # config.stdio_user = -> { User.find_by!(email: ENV.fetch("MCP_USER")) }
14
+ # Token-authenticated APIs: send headers your app already accepts.
15
+ # config.stdio_headers = -> { { "Authorization" => "Bearer #{ENV.fetch("MCP_TOKEN")}" } }
16
+
17
+ # Set to false to hide and refuse every tool declared with `write: true`.
18
+ # config.allow_writes = true
19
+
20
+ # Browser origins allowed to call the HTTP endpoint (non-browser clients send no Origin).
21
+ # config.allowed_origins = ["https://app.example.com"]
22
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module McpOnRuby
4
+ # Who is calling and over which transport. Passed to tools through the MCP server context.
5
+ class CallContext
6
+ attr_reader :transport, :origin_env, :user, :headers
7
+
8
+ # A call that arrived over HTTP; the client's credentials are forwarded from its request.
9
+ def self.http(env)
10
+ new(transport: :http, origin_env: env)
11
+ end
12
+
13
+ # A call from a local stdio session, acting as the configured user and headers.
14
+ def self.stdio(configuration = McpOnRuby.configuration)
15
+ new(
16
+ transport: :stdio,
17
+ user: configuration.stdio_user&.call,
18
+ headers: configuration.stdio_headers&.call || {}
19
+ )
20
+ end
21
+
22
+ def initialize(transport:, origin_env: nil, user: nil, headers: {})
23
+ @transport = transport
24
+ @origin_env = origin_env
25
+ @user = user
26
+ @headers = headers.to_h
27
+ end
28
+ end
29
+ end
@@ -1,74 +1,78 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'logger'
4
-
5
3
  module McpOnRuby
6
- # Configuration class for MCP server
4
+ # Settings for the MCP endpoint, set in config/initializers/mcp_on_ruby.rb:
5
+ #
6
+ # McpOnRuby.configure do |config|
7
+ # config.server_name = "my-app"
8
+ # config.instructions = "Tools for managing orders."
9
+ # end
7
10
  class Configuration
8
- attr_accessor :log_level,
9
- :path,
10
- :authentication_required,
11
- :authentication_token,
12
- :allowed_origins,
13
- :localhost_only,
14
- :rate_limit_per_minute,
15
- :enable_sse,
16
- :request_timeout,
17
- :cors_enabled,
18
- :dns_rebinding_protection
11
+ DEFAULT_TOOL_NAME = ->(controller_path, action) { "#{controller_path.tr("/", "_")}_#{action}" }
12
+
13
+ # Name reported to MCP clients. Defaults to the Rails application name.
14
+ attr_writer :server_name
15
+
16
+ # Version reported to MCP clients.
17
+ attr_accessor :server_version
18
+
19
+ # Server-level guidance sent to agents when they connect.
20
+ attr_accessor :instructions
21
+
22
+ # Callable returning the user that `bin/rails mcp:stdio` sessions act as (Warden/Devise apps).
23
+ attr_accessor :stdio_user
24
+
25
+ # Callable returning extra request headers for `bin/rails mcp:stdio` sessions (token-based apps).
26
+ attr_accessor :stdio_headers
27
+
28
+ # Warden scope used to sign in `stdio_user`.
29
+ attr_accessor :warden_scope
30
+
31
+ # Set to false to hide and refuse every tool declared with `write: true`.
32
+ attr_accessor :allow_writes
33
+
34
+ # Additional MCP::Tool classes served alongside controller tools.
35
+ attr_accessor :tools
36
+
37
+ # Browser origins allowed to call the HTTP endpoint.
38
+ attr_accessor :allowed_origins
39
+
40
+ # Maximum size of an MCP request body, in bytes.
41
+ attr_accessor :max_request_bytes
42
+
43
+ # Builds a tool's default name from its controller path and action.
44
+ attr_reader :tool_name
19
45
 
20
46
  def initialize
21
- @log_level = Logger::INFO
22
- @path = '/mcp'
23
- @authentication_required = false
24
- @authentication_token = nil
47
+ @server_name = nil
48
+ @server_version = nil
49
+ @instructions = nil
50
+ @stdio_user = nil
51
+ @stdio_headers = nil
52
+ @warden_scope = :user
53
+ @allow_writes = true
54
+ @tools = []
25
55
  @allowed_origins = []
26
- @localhost_only = false
27
- @rate_limit_per_minute = 60
28
- @enable_sse = true
29
- @request_timeout = 30
30
- @cors_enabled = true
31
- @dns_rebinding_protection = true
56
+ @max_request_bytes = 4 * 1024 * 1024
57
+ @tool_name = DEFAULT_TOOL_NAME
32
58
  end
33
59
 
34
- # Check if authentication is configured
35
- # @return [Boolean] True if authentication is properly configured
36
- def authentication_configured?
37
- authentication_required && !authentication_token.nil?
60
+ def tool_name=(callable)
61
+ raise ArgumentError, "tool_name must respond to #call" unless callable.respond_to?(:call)
62
+
63
+ @tool_name = callable
38
64
  end
39
65
 
40
- # Check if origin is allowed
41
- # @param origin [String] The origin to check
42
- # @return [Boolean] True if origin is allowed
43
- def origin_allowed?(origin)
44
- return true if allowed_origins.empty?
45
-
46
- allowed_origins.any? do |allowed|
47
- case allowed
48
- when String
49
- origin == allowed
50
- when Regexp
51
- origin =~ allowed
52
- else
53
- false
54
- end
55
- end
66
+ def server_name
67
+ @server_name || default_server_name
56
68
  end
57
69
 
58
- # Check if localhost only mode and origin is localhost
59
- # @param origin [String] The origin to check
60
- # @return [Boolean] True if localhost only and origin is localhost
61
- def localhost_allowed?(origin)
62
- return true unless localhost_only
63
-
64
- localhost_patterns = [
65
- 'http://localhost',
66
- 'https://localhost',
67
- 'http://127.0.0.1',
68
- 'https://127.0.0.1'
69
- ]
70
-
71
- localhost_patterns.any? { |pattern| origin&.start_with?(pattern) }
70
+ private
71
+
72
+ def default_server_name
73
+ return "rails-app" unless defined?(Rails) && Rails.application
74
+
75
+ Rails.application.class.module_parent_name.underscore.dasherize
72
76
  end
73
77
  end
74
- end
78
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module McpOnRuby
4
+ # Included into every Rails controller by the engine.
5
+ #
6
+ # class OrdersController < ApplicationController
7
+ # mcp_tool :index, description: "Search orders", params: { status: :string }
8
+ # mcp_tool :update, description: "Update an order", write: true
9
+ # end
10
+ module Controller
11
+ extend ActiveSupport::Concern
12
+
13
+ included do
14
+ class_attribute :mcp_declarations, instance_accessor: false, default: {}.freeze
15
+ helper_method :mcp_request? if respond_to?(:helper_method)
16
+ end
17
+
18
+ class_methods do
19
+ # Exposes an action of this controller as an MCP tool.
20
+ #
21
+ # @param action [Symbol] the controller action
22
+ # @param description [String] what the tool does, written for the agent
23
+ # @param title [String] optional human-readable name
24
+ # @param name [String] tool name; defaults to "<controller>_<action>"
25
+ # @param params [Hash] parameter schema, see McpOnRuby::Schema
26
+ # @param write [Boolean] required for actions routed with POST, PUT, PATCH, or DELETE
27
+ # @param annotations [Hash] overrides for the hints derived from the HTTP verb
28
+ def mcp_tool(action, **, &)
29
+ declaration = Declaration.new(action:, **, &)
30
+ self.mcp_declarations = mcp_declarations.merge(declaration.action => declaration).freeze
31
+ declaration
32
+ end
33
+ end
34
+
35
+ # True while this action is running because an MCP client called it.
36
+ def mcp_request?
37
+ request.env.key?(McpOnRuby::TOOL_ENV_KEY)
38
+ end
39
+
40
+ # The name of the MCP tool being called, or nil outside MCP requests.
41
+ def mcp_tool_name
42
+ request.env[McpOnRuby::TOOL_ENV_KEY]
43
+ end
44
+
45
+ # The user a local `bin/rails mcp:stdio` session acts as, from `config.stdio_user`.
46
+ # Apps without Warden can use this in their own authentication filter.
47
+ def mcp_user
48
+ request.env[McpOnRuby::USER_ENV_KEY]
49
+ end
50
+
51
+ private
52
+
53
+ # Requests dispatched by an MCP tool call pass CSRF verification. They cannot be forged
54
+ # cross-site: the flag lives in the Rack env and is set only by the dispatcher, and the MCP
55
+ # endpoint itself rejects requests from foreign browser origins.
56
+ def verified_request?
57
+ mcp_request? || super
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module McpOnRuby
4
+ # What a controller declared with `mcp_tool`, before it is matched to a route.
5
+ class Declaration
6
+ attr_reader :action, :description, :title, :name, :input_schema, :annotations
7
+
8
+ def initialize(action:, description:, title: nil, name: nil, params: {}, write: false, annotations: {}, &)
9
+ @action = action.to_s
10
+ @description = description.to_s.strip
11
+ @title = title
12
+ @name = name&.to_s
13
+ @write = write ? true : false
14
+ @annotations = annotations.to_h.transform_keys(&:to_sym)
15
+ @input_schema = Schema.build(params, &)
16
+
17
+ raise ArgumentError, "mcp_tool :#{@action} needs a description" if @description.empty?
18
+ end
19
+
20
+ def write?
21
+ @write
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/mock"
4
+
5
+ module McpOnRuby
6
+ # Runs a tool call as an in-process request through the full Rails stack:
7
+ # middleware, routes, filters, and the controller action itself.
8
+ class Dispatcher
9
+ # Headers copied from the MCP client's HTTP request so the app sees the same caller.
10
+ FORWARDED_HEADERS = %w[
11
+ HTTP_AUTHORIZATION HTTP_COOKIE HTTP_USER_AGENT HTTP_ACCEPT_LANGUAGE HTTP_X_REQUEST_ID
12
+ HTTP_X_FORWARDED_FOR HTTP_X_FORWARDED_PROTO HTTP_X_FORWARDED_HOST REMOTE_ADDR
13
+ ].freeze
14
+
15
+ EVENT = "tool_call.mcp_on_ruby"
16
+
17
+ def initialize(app: nil)
18
+ @app = app
19
+ end
20
+
21
+ # @return [MCP::Tool::Response]
22
+ def call(spec, arguments, context)
23
+ payload = { tool: spec.name, controller: spec.controller.name, action: spec.action,
24
+ transport: context.transport }
25
+
26
+ ActiveSupport::Notifications.instrument(EVENT, payload) do
27
+ status, response = dispatch(spec, arguments, context)
28
+ payload[:status] = status
29
+ payload[:error] = response.error?
30
+ response
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def app
37
+ @app || Rails.application
38
+ end
39
+
40
+ def dispatch(spec, arguments, context)
41
+ status, headers, body = app.call(build_env(spec, arguments, context))
42
+ [status, Result.from_rack(status, headers, body)]
43
+ rescue StandardError => e
44
+ Rails.error.report(e, handled: true, context: { mcp_tool: spec.name })
45
+ [500, Result.internal_error]
46
+ end
47
+
48
+ def build_env(spec, arguments, context)
49
+ arguments = arguments.deep_stringify_keys
50
+ path_values = spec.path_params.to_h { |param| [param.to_sym, arguments.delete(param)] }
51
+ read = ToolSpec::READ_VERBS.include?(spec.verb)
52
+ path = path_for(spec, path_values.merge(read ? arguments.symbolize_keys : {}))
53
+
54
+ host = host(context)
55
+ env = Rack::MockRequest.env_for(
56
+ "#{scheme(context)}://#{host}#{path}",
57
+ method: spec.verb,
58
+ input: read ? "" : JSON.generate(arguments),
59
+ "CONTENT_TYPE" => "application/json",
60
+ "HTTP_ACCEPT" => "application/json",
61
+ "HTTP_HOST" => host
62
+ )
63
+ apply_identity(env, context)
64
+ env[TOOL_ENV_KEY] = spec.name
65
+ env
66
+ end
67
+
68
+ def path_for(spec, params)
69
+ app.routes.path_for(controller: spec.controller_path, action: spec.action, **params)
70
+ end
71
+
72
+ def apply_identity(env, context)
73
+ context.origin_env&.slice(*FORWARDED_HEADERS)&.each { |key, value| env[key] = value }
74
+ context.headers.each { |name, value| env[rack_header(name)] = value.to_s }
75
+ env[USER_ENV_KEY] = context.user if context.user
76
+ end
77
+
78
+ def rack_header(name)
79
+ "HTTP_#{name.to_s.upcase.tr("-", "_")}"
80
+ end
81
+
82
+ def host(context)
83
+ context.origin_env&.fetch("HTTP_HOST", nil) || local_host
84
+ end
85
+
86
+ # Host for stdio sessions: one the app's host authorization already accepts.
87
+ def local_host
88
+ app.routes.default_url_options[:host] ||
89
+ app.config.hosts.find { |host| host.is_a?(String) && !host.start_with?(".") } ||
90
+ "localhost"
91
+ end
92
+
93
+ def scheme(context)
94
+ context.origin_env&.fetch("rack.url_scheme", nil) || "http"
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module McpOnRuby
4
+ # Checks an app's MCP setup for `bin/rails mcp:doctor`.
5
+ class Doctor
6
+ Check = Struct.new(:status, :message)
7
+
8
+ attr_reader :checks
9
+
10
+ def initialize(app: Rails.application, registry: McpOnRuby.registry, configuration: McpOnRuby.configuration)
11
+ @app = app
12
+ @registry = registry
13
+ @configuration = configuration
14
+ @checks = []
15
+ end
16
+
17
+ def run
18
+ check_mount
19
+ check_registry
20
+ check_stdio_identity
21
+ self
22
+ end
23
+
24
+ def ok?
25
+ checks.none? { |check| check.status == :fail }
26
+ end
27
+
28
+ def report
29
+ checks.map { |check| "#{check.status.to_s.upcase.ljust(4)} #{check.message}" }.join("\n") << "\n"
30
+ end
31
+
32
+ private
33
+
34
+ def pass(message) = checks << Check.new(:ok, message)
35
+ def warn(message) = checks << Check.new(:warn, message)
36
+ def fail(message) = checks << Check.new(:fail, message)
37
+
38
+ def check_mount
39
+ route = @app.routes.routes.find { |candidate| candidate.app.app == McpOnRuby::Engine }
40
+ if route
41
+ pass("Engine mounted at #{route.path.spec.to_s.delete_suffix("(.:format)")}")
42
+ else
43
+ warn('Engine is not mounted; add `mount McpOnRuby::Engine, at: "/mcp"` to config/routes.rb for HTTP clients')
44
+ end
45
+ end
46
+
47
+ def check_registry
48
+ specs = @registry.tools
49
+ if specs.empty?
50
+ warn("No tools are exposed yet; add `mcp_tool` declarations to your controllers")
51
+ else
52
+ writes = specs.count(&:write?)
53
+ pass("#{specs.size} tools exposed (#{specs.size - writes} read-only, #{writes} write)")
54
+ end
55
+ rescue ConfigurationError => e
56
+ fail(e.message)
57
+ end
58
+
59
+ def check_stdio_identity
60
+ if @configuration.stdio_user || @configuration.stdio_headers
61
+ pass("stdio sessions have an identity (#{@configuration.stdio_user ? "stdio_user" : "stdio_headers"})")
62
+ else
63
+ warn("No stdio identity: set config.stdio_user or config.stdio_headers to use `bin/rails mcp:stdio`")
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module McpOnRuby
4
+ # The Rack app behind `mount McpOnRuby::Engine, at: "/mcp"`.
5
+ #
6
+ # Each request gets its own stateless Streamable HTTP transport and server, so tools run as the
7
+ # caller whose credentials arrived with that request, and any process can serve any request.
8
+ class Endpoint
9
+ def initialize(factory: nil, configuration: nil)
10
+ @factory = factory
11
+ @configuration = configuration
12
+ end
13
+
14
+ def call(env)
15
+ server = factory.build(CallContext.http(env))
16
+ transport(server, Rack::Request.new(env)).call(env)
17
+ end
18
+
19
+ private
20
+
21
+ def factory
22
+ @factory || McpOnRuby.server_factory
23
+ end
24
+
25
+ def configuration
26
+ @configuration || McpOnRuby.configuration
27
+ end
28
+
29
+ # Rails' host authorization has already accepted this request's Host, so the transport only needs
30
+ # to enforce the Origin check that protects local servers from malicious web pages.
31
+ def transport(server, request)
32
+ MCP::Server::Transports::StreamableHTTPTransport.new(
33
+ server,
34
+ stateless: true,
35
+ enable_json_response: true,
36
+ serve_subscriptions_listen: false,
37
+ allowed_hosts: [request.host, request.host_with_port].uniq,
38
+ allowed_origins: configuration.allowed_origins,
39
+ max_request_bytes: configuration.max_request_bytes
40
+ )
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module McpOnRuby
6
+ # Mount in config/routes.rb:
7
+ #
8
+ # mount McpOnRuby::Engine, at: "/mcp"
9
+ class Engine < ::Rails::Engine
10
+ isolate_namespace McpOnRuby
11
+
12
+ initializer "mcp_on_ruby.controller" do
13
+ ActiveSupport.on_load(:action_controller) do
14
+ include McpOnRuby::Controller
15
+ end
16
+ end
17
+
18
+ # Controllers and routes can change on reload; resolve tools again on next use.
19
+ config.to_prepare do
20
+ McpOnRuby.registry.reset!
21
+ end
22
+
23
+ # Fail at boot, not on the first agent request, when declarations do not match routes.
24
+ config.after_initialize do |app|
25
+ McpOnRuby::WardenHook.install!
26
+ McpOnRuby.registry.tools if app.config.eager_load
27
+ end
28
+ end
29
+ end