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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +39 -3
- data/README.md +359 -163
- data/config/routes.rb +5 -0
- data/lib/generators/mcp_on_ruby/expose/expose_generator.rb +108 -0
- data/lib/generators/mcp_on_ruby/install/install_generator.rb +37 -0
- data/lib/generators/mcp_on_ruby/install/templates/initializer.rb +22 -0
- data/lib/mcp_on_ruby/call_context.rb +29 -0
- data/lib/mcp_on_ruby/configuration.rb +63 -59
- data/lib/mcp_on_ruby/controller.rb +60 -0
- data/lib/mcp_on_ruby/declaration.rb +24 -0
- data/lib/mcp_on_ruby/dispatcher.rb +97 -0
- data/lib/mcp_on_ruby/doctor.rb +67 -0
- data/lib/mcp_on_ruby/endpoint.rb +43 -0
- data/lib/mcp_on_ruby/engine.rb +29 -0
- data/lib/mcp_on_ruby/registry.rb +146 -0
- data/lib/mcp_on_ruby/result.rb +83 -0
- data/lib/mcp_on_ruby/rspec.rb +8 -0
- data/lib/mcp_on_ruby/schema.rb +149 -0
- data/lib/mcp_on_ruby/server_factory.rb +66 -0
- data/lib/mcp_on_ruby/stdio.rb +42 -0
- data/lib/mcp_on_ruby/test_helper.rb +86 -0
- data/lib/mcp_on_ruby/tool_spec.rb +87 -0
- data/lib/mcp_on_ruby/tool_table.rb +31 -0
- data/lib/mcp_on_ruby/version.rb +2 -3
- data/lib/mcp_on_ruby/warden_hook.rb +21 -0
- data/lib/mcp_on_ruby.rb +44 -122
- data/lib/tasks/mcp_on_ruby.rake +20 -0
- metadata +65 -136
- data/CODE_OF_CONDUCT.md +0 -56
- data/CONTRIBUTING.md +0 -86
- data/bin/console +0 -11
- data/bin/setup +0 -6
- data/docs/advanced-usage.md +0 -132
- data/docs/api-reference.md +0 -35
- data/docs/testing.md +0 -55
- data/examples/claude/README.md +0 -171
- data/examples/claude/claude-bridge.js +0 -122
- data/lib/mcp_on_ruby/errors.rb +0 -137
- data/lib/mcp_on_ruby/generators/install_generator.rb +0 -46
- data/lib/mcp_on_ruby/generators/resource_generator.rb +0 -63
- data/lib/mcp_on_ruby/generators/templates/README +0 -31
- data/lib/mcp_on_ruby/generators/templates/application_resource.rb +0 -20
- data/lib/mcp_on_ruby/generators/templates/application_tool.rb +0 -18
- data/lib/mcp_on_ruby/generators/templates/initializer.rb +0 -41
- data/lib/mcp_on_ruby/generators/templates/resource.rb +0 -50
- data/lib/mcp_on_ruby/generators/templates/resource_spec.rb +0 -67
- data/lib/mcp_on_ruby/generators/templates/sample_resource.rb +0 -57
- data/lib/mcp_on_ruby/generators/templates/sample_tool.rb +0 -59
- data/lib/mcp_on_ruby/generators/templates/tool.rb +0 -38
- data/lib/mcp_on_ruby/generators/templates/tool_spec.rb +0 -55
- data/lib/mcp_on_ruby/generators/tool_generator.rb +0 -51
- data/lib/mcp_on_ruby/railtie.rb +0 -108
- data/lib/mcp_on_ruby/resource.rb +0 -161
- data/lib/mcp_on_ruby/server.rb +0 -378
- data/lib/mcp_on_ruby/tool.rb +0 -134
- 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
|
-
#
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
@
|
|
22
|
-
@
|
|
23
|
-
@
|
|
24
|
-
@
|
|
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
|
-
@
|
|
27
|
-
@
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|