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,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module McpOnRuby
|
|
4
|
+
# Matches `mcp_tool` declarations to the application's routes and validates them.
|
|
5
|
+
#
|
|
6
|
+
# Tools are resolved lazily and cached until #reset!, which the engine calls on every code reload.
|
|
7
|
+
class Registry
|
|
8
|
+
WRITE_VERB_PREFERENCE = %w[PATCH PUT POST DELETE].freeze
|
|
9
|
+
|
|
10
|
+
# @param routes [ActionDispatch::Routing::RouteSet] defaults to the application's routes
|
|
11
|
+
# @param configuration [McpOnRuby::Configuration] defaults to McpOnRuby.configuration
|
|
12
|
+
# @param root [String, Pathname] only controllers defined under this directory are checked for
|
|
13
|
+
# declarations without a route; defaults to Rails.root
|
|
14
|
+
def initialize(routes: nil, configuration: nil, root: nil)
|
|
15
|
+
@routes = routes
|
|
16
|
+
@configuration = configuration
|
|
17
|
+
@root = root
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
@tools = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# All exposed tools, including write tools.
|
|
23
|
+
def tools
|
|
24
|
+
@tools || @mutex.synchronize { @tools ||= build }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def find(name)
|
|
28
|
+
tools.find { |tool| tool.name == name }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def reset!
|
|
32
|
+
@mutex.synchronize { @tools = nil }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def routes
|
|
38
|
+
@routes || Rails.application.routes
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def configuration
|
|
42
|
+
@configuration || McpOnRuby.configuration
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def build
|
|
46
|
+
tools = routed_actions.filter_map do |(controller, action), route_list|
|
|
47
|
+
declaration = controller.mcp_declarations[action]
|
|
48
|
+
build_tool(controller, declaration, route_list) if declaration
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
check_unrouted_declarations!(tools)
|
|
52
|
+
check_unique_names!(tools)
|
|
53
|
+
tools.sort_by(&:name).freeze
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# { [controller_class, action] => [routes...] } for every routed controller action.
|
|
57
|
+
def routed_actions
|
|
58
|
+
routes.routes.each_with_object({}) do |route, actions|
|
|
59
|
+
controller = controller_for(route)
|
|
60
|
+
next unless controller
|
|
61
|
+
|
|
62
|
+
(actions[[controller, route.defaults[:action].to_s]] ||= []) << route
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def controller_for(route)
|
|
67
|
+
path = route.defaults[:controller]
|
|
68
|
+
return unless path && route.defaults[:action]
|
|
69
|
+
|
|
70
|
+
controller = "#{path.camelize}Controller".safe_constantize
|
|
71
|
+
controller if controller.respond_to?(:mcp_declarations)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def build_tool(controller, declaration, route_list)
|
|
75
|
+
route = pick_route(route_list)
|
|
76
|
+
verb = verb_for(route)
|
|
77
|
+
|
|
78
|
+
if !ToolSpec::READ_VERBS.include?(verb) && !declaration.write?
|
|
79
|
+
raise ConfigurationError,
|
|
80
|
+
"#{controller.name}##{declaration.action} is routed as #{verb}; " \
|
|
81
|
+
"add `write: true` to its mcp_tool declaration to expose it"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
ToolSpec.new(
|
|
85
|
+
name: declaration.name || configuration.tool_name.call(controller.controller_path, declaration.action),
|
|
86
|
+
controller:, action: declaration.action, verb:,
|
|
87
|
+
path: route.path.spec.to_s.delete_suffix("(.:format)"),
|
|
88
|
+
path_params: route.required_parts.map(&:to_s) - ["format"],
|
|
89
|
+
declaration:
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Prefers a read route; among write routes prefers PATCH over PUT, as Rails' resources do.
|
|
94
|
+
def pick_route(route_list)
|
|
95
|
+
route_list.min_by do |route|
|
|
96
|
+
verb = verb_for(route)
|
|
97
|
+
ToolSpec::READ_VERBS.include?(verb) ? -1 : WRITE_VERB_PREFERENCE.index(verb) || WRITE_VERB_PREFERENCE.size
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def verb_for(route)
|
|
102
|
+
verbs = route.verb.to_s.split("|").map(&:upcase)
|
|
103
|
+
return "GET" if verbs.empty? || verbs.include?("GET")
|
|
104
|
+
|
|
105
|
+
verbs.first
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def check_unrouted_declarations!(tools)
|
|
109
|
+
exposed = tools.to_set { |tool| [tool.controller, tool.action] }
|
|
110
|
+
|
|
111
|
+
declaring_controllers.each do |controller|
|
|
112
|
+
controller.mcp_declarations.each_key do |action|
|
|
113
|
+
next if exposed.include?([controller, action]) || inherited_and_routed_elsewhere?(controller, action, exposed)
|
|
114
|
+
|
|
115
|
+
raise ConfigurationError,
|
|
116
|
+
"#{controller.name} declares mcp_tool :#{action}, but no route points to #{controller.name}##{action}"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Loaded controllers with declarations whose source lives in the application, not in gems or engines.
|
|
122
|
+
def declaring_controllers
|
|
123
|
+
root = (@root || Rails.root).to_s
|
|
124
|
+
[ActionController::Base, ActionController::API].flat_map(&:descendants).select do |controller|
|
|
125
|
+
next false unless controller.name && controller.mcp_declarations.any?
|
|
126
|
+
|
|
127
|
+
Object.const_source_location(controller.name)&.first.to_s.start_with?(root)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Base controllers may declare tools that only their routed subclasses serve.
|
|
132
|
+
def inherited_and_routed_elsewhere?(controller, action, exposed)
|
|
133
|
+
exposed.any? { |klass, routed_action| routed_action == action && klass < controller }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def check_unique_names!(tools)
|
|
137
|
+
tools.group_by(&:name).each do |name, duplicates|
|
|
138
|
+
next if duplicates.one?
|
|
139
|
+
|
|
140
|
+
actions = duplicates.map { |tool| "#{tool.controller.name}##{tool.action}" }.join(" and ")
|
|
141
|
+
raise ConfigurationError,
|
|
142
|
+
"tool name #{name.inspect} is used by #{actions}; pass name: to mcp_tool to disambiguate"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module McpOnRuby
|
|
4
|
+
# Turns the controller's Rack response into an MCP tool result an agent can act on.
|
|
5
|
+
module Result
|
|
6
|
+
JSON_TYPE = %r{\Aapplication/(?:[\w.+-]+\+)?json\b}i
|
|
7
|
+
HTML_TYPE = %r{\Atext/html\b}i
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def from_rack(status, headers, body)
|
|
12
|
+
text = read(body)
|
|
13
|
+
content_type = header(headers, "content-type").to_s
|
|
14
|
+
json = parse_json(text) if content_type.match?(JSON_TYPE)
|
|
15
|
+
|
|
16
|
+
case status
|
|
17
|
+
when 200..299 then success(status, text, json, content_type)
|
|
18
|
+
when 300..399 then error(status, "Redirected to #{header(headers, "location") || "another page"}; " \
|
|
19
|
+
"redirects are not followed")
|
|
20
|
+
when 400..499 then client_error(status, text, json)
|
|
21
|
+
else error(status, "Internal server error (#{status})")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def internal_error
|
|
26
|
+
error(500, "Internal server error (500)")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def success(status, text, json, content_type)
|
|
30
|
+
return text_response("Success (#{status}), no content") if text.empty?
|
|
31
|
+
return MCP::Tool::Response.new([text_block(text)], structured_content: json) unless json.nil?
|
|
32
|
+
if content_type.match?(HTML_TYPE)
|
|
33
|
+
return error(status, "The action responded with HTML; render JSON for MCP tools")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
text_response(text)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def client_error(status, text, json)
|
|
40
|
+
message = {
|
|
41
|
+
401 => "Not authenticated (401)",
|
|
42
|
+
403 => "Not permitted (403)",
|
|
43
|
+
404 => "Not found (404)",
|
|
44
|
+
422 => "Unprocessable (422)"
|
|
45
|
+
}.fetch(status) { "Request failed (#{status})" }
|
|
46
|
+
|
|
47
|
+
detail = json.nil? ? nil : text
|
|
48
|
+
error(status, [message, detail].compact.join(": "), json)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def error(status, message, body = nil)
|
|
52
|
+
structured = { "status" => status }
|
|
53
|
+
structured["body"] = body unless body.nil?
|
|
54
|
+
MCP::Tool::Response.new([text_block(message)], structured_content: structured, error: true)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def text_response(text)
|
|
58
|
+
MCP::Tool::Response.new([text_block(text)])
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def text_block(text)
|
|
62
|
+
{ type: "text", text: }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def read(body)
|
|
66
|
+
text = +""
|
|
67
|
+
body.each { |chunk| text << chunk.to_s }
|
|
68
|
+
text
|
|
69
|
+
ensure
|
|
70
|
+
body.close if body.respond_to?(:close)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def header(headers, name)
|
|
74
|
+
headers[name] || headers.find { |key, _| key.to_s.casecmp?(name) }&.last
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def parse_json(text)
|
|
78
|
+
JSON.parse(text) unless text.empty?
|
|
79
|
+
rescue JSON::ParserError
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module McpOnRuby
|
|
4
|
+
# Builds the JSON Schema for a tool's input from a compact Ruby DSL.
|
|
5
|
+
#
|
|
6
|
+
# Schema.build(q: :string, status: { type: :string, enum: %w[paid pending], required: true })
|
|
7
|
+
#
|
|
8
|
+
# Schema.build do
|
|
9
|
+
# param :order, type: :object, required: true do
|
|
10
|
+
# param :sku, type: :string, required: true
|
|
11
|
+
# param :quantity, type: :integer, minimum: 1
|
|
12
|
+
# end
|
|
13
|
+
# end
|
|
14
|
+
class Schema
|
|
15
|
+
TYPES = %w[string integer number boolean array object null].freeze
|
|
16
|
+
|
|
17
|
+
# Ruby-style option names and the JSON Schema keywords they map to.
|
|
18
|
+
KEYWORDS = {
|
|
19
|
+
description: :description,
|
|
20
|
+
title: :title,
|
|
21
|
+
enum: :enum,
|
|
22
|
+
const: :const,
|
|
23
|
+
default: :default,
|
|
24
|
+
examples: :examples,
|
|
25
|
+
format: :format,
|
|
26
|
+
pattern: :pattern,
|
|
27
|
+
minimum: :minimum,
|
|
28
|
+
maximum: :maximum,
|
|
29
|
+
exclusive_minimum: :exclusiveMinimum,
|
|
30
|
+
exclusive_maximum: :exclusiveMaximum,
|
|
31
|
+
multiple_of: :multipleOf,
|
|
32
|
+
min_length: :minLength,
|
|
33
|
+
max_length: :maxLength,
|
|
34
|
+
min_items: :minItems,
|
|
35
|
+
max_items: :maxItems,
|
|
36
|
+
unique_items: :uniqueItems
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
def self.build(params = {}, &)
|
|
40
|
+
new.tap do |schema|
|
|
41
|
+
schema.params(params)
|
|
42
|
+
schema.instance_eval(&) if block_given?
|
|
43
|
+
end.to_h
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def initialize
|
|
47
|
+
@properties = {}
|
|
48
|
+
@required = []
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Declares several parameters from a hash of `name => type` or `name => options`.
|
|
52
|
+
def params(definitions)
|
|
53
|
+
definitions.each do |name, definition|
|
|
54
|
+
case definition
|
|
55
|
+
when Hash then param(name, **definition)
|
|
56
|
+
else param(name, type: definition)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
self
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Declares a single parameter. A block describes the properties of an object,
|
|
63
|
+
# or of the objects inside an array.
|
|
64
|
+
def param(name, type: nil, required: false, nullable: false, items: nil, **options, &)
|
|
65
|
+
name = name.to_s
|
|
66
|
+
raise ArgumentError, "parameter #{name.inspect} is declared twice" if @properties.key?(name)
|
|
67
|
+
|
|
68
|
+
type ||= "object" if block_given?
|
|
69
|
+
raise ArgumentError, "parameter #{name.inspect} needs a type" unless type
|
|
70
|
+
|
|
71
|
+
@properties[name] = property(name, type, nullable:, items:, options:, &)
|
|
72
|
+
@required << name if required
|
|
73
|
+
self
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def to_h
|
|
77
|
+
schema = { type: "object", properties: @properties }
|
|
78
|
+
schema[:required] = @required unless @required.empty?
|
|
79
|
+
schema
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def property(name, type, nullable:, items:, options:, &)
|
|
85
|
+
types = json_types(name, type, nullable)
|
|
86
|
+
schema = type_schema(types)
|
|
87
|
+
schema.merge!(keywords(name, options))
|
|
88
|
+
schema.merge!(nested(name, types, items, &))
|
|
89
|
+
schema
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def json_types(name, type, nullable)
|
|
93
|
+
types = Array(type).map(&:to_s)
|
|
94
|
+
unknown = types - TYPES
|
|
95
|
+
raise ArgumentError, "parameter #{name.inspect} has unknown type #{unknown.join(", ")}" if unknown.any?
|
|
96
|
+
|
|
97
|
+
types << "null" if nullable && !types.include?("null")
|
|
98
|
+
types
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Several types become `anyOf` branches rather than a `type` array, which some model
|
|
102
|
+
# providers' schema dialects reject.
|
|
103
|
+
def type_schema(types)
|
|
104
|
+
types.one? ? { type: types.first } : { anyOf: types.map { |type| { type: } } }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def keywords(name, options)
|
|
108
|
+
options.each_with_object({}) do |(option, value), schema|
|
|
109
|
+
keyword = KEYWORDS.fetch(option) do
|
|
110
|
+
raise ArgumentError, "parameter #{name.inspect} has unknown option #{option.inspect}"
|
|
111
|
+
end
|
|
112
|
+
schema[keyword] = value
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def nested(name, types, items, &)
|
|
117
|
+
if types.include?("array")
|
|
118
|
+
array_items(name, items, &)
|
|
119
|
+
elsif block_given?
|
|
120
|
+
unless types.include?("object")
|
|
121
|
+
raise ArgumentError, "parameter #{name.inspect} takes a block only as an object or array"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
nested_object(&)
|
|
125
|
+
else
|
|
126
|
+
{}
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def array_items(name, items, &)
|
|
131
|
+
raise ArgumentError, "parameter #{name.inspect} takes either items: or a block, not both" if items && block_given?
|
|
132
|
+
|
|
133
|
+
return { items: { type: "object" }.merge(nested_object(&)) } if block_given?
|
|
134
|
+
return {} unless items
|
|
135
|
+
|
|
136
|
+
{ items: items.is_a?(Hash) ? item_schema(name, **items) : type_schema(json_types(name, items, false)) }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def item_schema(name, type:, nullable: false, **options)
|
|
140
|
+
type_schema(json_types(name, type, nullable)).merge(keywords(name, options))
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def nested_object(&)
|
|
144
|
+
object = self.class.new
|
|
145
|
+
object.instance_eval(&)
|
|
146
|
+
object.to_h.except(:type)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module McpOnRuby
|
|
4
|
+
# Builds the official MCP::Server for one caller, serving the registry's tools.
|
|
5
|
+
class ServerFactory
|
|
6
|
+
CONTEXT_KEY = :mcp_on_ruby
|
|
7
|
+
|
|
8
|
+
def initialize(registry: nil, configuration: nil, dispatcher: nil)
|
|
9
|
+
@registry = registry
|
|
10
|
+
@configuration = configuration
|
|
11
|
+
@dispatcher = dispatcher
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param context [McpOnRuby::CallContext] who is calling; tools dispatch as this caller
|
|
16
|
+
# @return [MCP::Server]
|
|
17
|
+
def build(context)
|
|
18
|
+
options = {
|
|
19
|
+
name: configuration.server_name,
|
|
20
|
+
instructions: configuration.instructions,
|
|
21
|
+
tools: mcp_tools + Array(configuration.tools),
|
|
22
|
+
server_context: { CONTEXT_KEY => context }
|
|
23
|
+
}
|
|
24
|
+
options[:version] = configuration.server_version if configuration.server_version
|
|
25
|
+
MCP::Server.new(**options.compact)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The tool specs currently served, honoring `allow_writes`.
|
|
29
|
+
def served_specs
|
|
30
|
+
specs = registry.tools
|
|
31
|
+
configuration.allow_writes ? specs : specs.reject(&:write?)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def registry
|
|
37
|
+
@registry || McpOnRuby.registry
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def configuration
|
|
41
|
+
@configuration || McpOnRuby.configuration
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def dispatcher
|
|
45
|
+
@dispatcher ||= Dispatcher.new
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Tool classes are rebuilt only when the registry is (on boot and code reload).
|
|
49
|
+
def mcp_tools
|
|
50
|
+
specs = served_specs
|
|
51
|
+
@mutex.synchronize do
|
|
52
|
+
@cache = [specs, specs.map { |spec| spec.to_mcp_tool(handler) }] unless @cache&.first.equal?(specs)
|
|
53
|
+
@cache.last
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def handler
|
|
58
|
+
@handler ||= lambda do |spec, arguments, server_context|
|
|
59
|
+
context = server_context && server_context[CONTEXT_KEY]
|
|
60
|
+
raise Error, "tool #{spec.name} was called without a McpOnRuby::CallContext" unless context
|
|
61
|
+
|
|
62
|
+
dispatcher.call(spec, arguments, context)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module McpOnRuby
|
|
4
|
+
# Serves the app's tools over stdio for local agents (Claude Code, Claude Desktop, Cursor, …).
|
|
5
|
+
#
|
|
6
|
+
# Started by `bin/rails mcp:stdio`. Protocol frames go to the original stdout; anything else the
|
|
7
|
+
# app prints, including loggers attached to STDOUT, is redirected to stderr so it cannot corrupt
|
|
8
|
+
# the stream.
|
|
9
|
+
module Stdio
|
|
10
|
+
# The SDK's stdio transport, writing frames to a dedicated IO instead of $stdout.
|
|
11
|
+
class Transport < MCP::Server::Transports::StdioTransport
|
|
12
|
+
def initialize(server, output:)
|
|
13
|
+
super(server)
|
|
14
|
+
@output = output
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def send_response(message)
|
|
18
|
+
@output.puts(message.is_a?(String) ? message : JSON.generate(message))
|
|
19
|
+
@output.flush
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def run(configuration: McpOnRuby.configuration, factory: McpOnRuby.server_factory)
|
|
26
|
+
output = $stdout.dup
|
|
27
|
+
$stdout.reopen($stderr)
|
|
28
|
+
|
|
29
|
+
context = CallContext.stdio(configuration)
|
|
30
|
+
warn_without_identity(context)
|
|
31
|
+
factory.served_specs # fail fast on invalid declarations
|
|
32
|
+
Transport.new(factory.build(context), output:).open
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def warn_without_identity(context)
|
|
36
|
+
return if context.user || context.headers.any?
|
|
37
|
+
|
|
38
|
+
warn "[mcp_on_ruby] No stdio identity configured; tools run unauthenticated. " \
|
|
39
|
+
"Set config.stdio_user or config.stdio_headers in config/initializers/mcp_on_ruby.rb."
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module McpOnRuby
|
|
4
|
+
# Test helpers for apps using MCP on Ruby. Calls go through the real MCP server and your real
|
|
5
|
+
# controllers, so schema, authentication, and authorization mistakes all surface in tests.
|
|
6
|
+
#
|
|
7
|
+
# Minitest:
|
|
8
|
+
# class ActiveSupport::TestCase
|
|
9
|
+
# include McpOnRuby::TestHelper
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# RSpec:
|
|
13
|
+
# require "mcp_on_ruby/rspec"
|
|
14
|
+
module TestHelper
|
|
15
|
+
# The outcome of a tool call made in a test.
|
|
16
|
+
class Result
|
|
17
|
+
attr_reader :response
|
|
18
|
+
|
|
19
|
+
def initialize(response)
|
|
20
|
+
@response = response
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def success?
|
|
24
|
+
!error?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def error?
|
|
28
|
+
result["isError"] == true || response.key?("error")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Parsed JSON the action rendered (or, for errors, `{ "status" => ..., "body" => ... }`).
|
|
32
|
+
def structured
|
|
33
|
+
result["structuredContent"]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# HTTP status of a failed call, e.g. 403. Nil for successful calls.
|
|
37
|
+
def status
|
|
38
|
+
structured["status"] if result["isError"] && structured.is_a?(Hash)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Text content the agent sees.
|
|
42
|
+
def text
|
|
43
|
+
Array(result["content"]).filter_map { |block| block["text"] }.join("\n")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# JSON-RPC error message for protocol-level failures, such as an unknown tool or invalid arguments.
|
|
47
|
+
def protocol_error
|
|
48
|
+
response.dig("error", "data") || response.dig("error", "message")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def inspect
|
|
52
|
+
"#<#{self.class.name} #{error? ? "error" : "success"} #{text.inspect}>"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def result
|
|
58
|
+
response.fetch("result", {})
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Calls an MCP tool the way an agent would.
|
|
63
|
+
#
|
|
64
|
+
# @param tool [String, Symbol] tool name, e.g. :orders_index
|
|
65
|
+
# @param arguments [Hash] tool arguments
|
|
66
|
+
# @param as [Object] user to sign in through Warden/Devise (like `config.stdio_user`)
|
|
67
|
+
# @param headers [Hash] request headers, e.g. { "Authorization" => "Bearer token" }
|
|
68
|
+
# @return [McpOnRuby::TestHelper::Result]
|
|
69
|
+
def mcp_call(tool, arguments = {}, as: nil, headers: {})
|
|
70
|
+
Result.new(mcp_rpc("tools/call", { name: tool.to_s, arguments: }, as:, headers:))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Names of the tools an agent can see.
|
|
74
|
+
def mcp_tools
|
|
75
|
+
mcp_rpc("tools/list", {}).dig("result", "tools").map { |tool| tool["name"] }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def mcp_rpc(method, params, as: nil, headers: {})
|
|
81
|
+
context = CallContext.new(transport: :test, user: as, headers:)
|
|
82
|
+
server = McpOnRuby.server_factory.build(context)
|
|
83
|
+
JSON.parse(server.handle_json(JSON.generate(jsonrpc: "2.0", id: 1, method:, params:)))
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module McpOnRuby
|
|
4
|
+
# A declared action matched to its route: everything needed to list the tool and dispatch calls to it.
|
|
5
|
+
class ToolSpec
|
|
6
|
+
READ_VERBS = %w[GET HEAD].freeze
|
|
7
|
+
|
|
8
|
+
HINTS = {
|
|
9
|
+
"GET" => { read_only_hint: true, destructive_hint: false, idempotent_hint: true },
|
|
10
|
+
"POST" => { read_only_hint: false, destructive_hint: false, idempotent_hint: false },
|
|
11
|
+
"PUT" => { read_only_hint: false, destructive_hint: true, idempotent_hint: true },
|
|
12
|
+
"PATCH" => { read_only_hint: false, destructive_hint: true, idempotent_hint: true },
|
|
13
|
+
"DELETE" => { read_only_hint: false, destructive_hint: true, idempotent_hint: true }
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :name, :controller, :action, :verb, :path, :path_params, :declaration
|
|
17
|
+
|
|
18
|
+
def initialize(name:, controller:, action:, verb:, path:, path_params:, declaration:)
|
|
19
|
+
@name = name
|
|
20
|
+
@controller = controller
|
|
21
|
+
@action = action
|
|
22
|
+
@verb = verb
|
|
23
|
+
@path = path
|
|
24
|
+
@path_params = path_params
|
|
25
|
+
@declaration = declaration
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def controller_path
|
|
29
|
+
controller.controller_path
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def description
|
|
33
|
+
declaration.description
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def title
|
|
37
|
+
declaration.title
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def write?
|
|
41
|
+
declaration.write? || !READ_VERBS.include?(verb)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The declared parameters plus the route's path parameters, which are always required.
|
|
45
|
+
def input_schema
|
|
46
|
+
@input_schema ||= begin
|
|
47
|
+
schema = declaration.input_schema
|
|
48
|
+
path_properties = path_params.to_h { |param| [param, path_param_schema(param)] }
|
|
49
|
+
properties = path_properties.merge(schema[:properties])
|
|
50
|
+
required = (path_params + schema.fetch(:required, [])).uniq
|
|
51
|
+
|
|
52
|
+
{ type: "object", properties: }.tap { |result| result[:required] = required if required.any? }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def annotations
|
|
57
|
+
hints = HINTS.fetch(READ_VERBS.include?(verb) ? "GET" : verb, HINTS["POST"])
|
|
58
|
+
hints = hints.merge(read_only_hint: false) if write?
|
|
59
|
+
{ open_world_hint: false, title: }.compact.merge(hints).merge(declaration.annotations)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Builds the MCP::Tool class served for this spec. Calls are forwarded to the handler.
|
|
63
|
+
def to_mcp_tool(handler)
|
|
64
|
+
spec = self
|
|
65
|
+
Class.new(MCP::Tool) do
|
|
66
|
+
tool_name spec.name
|
|
67
|
+
title spec.title if spec.title
|
|
68
|
+
description spec.description
|
|
69
|
+
input_schema spec.input_schema
|
|
70
|
+
annotations spec.annotations
|
|
71
|
+
define_singleton_method(:call) do |server_context:, **arguments|
|
|
72
|
+
handler.call(spec, arguments, server_context)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def to_s
|
|
78
|
+
"#{name} (#{controller.name}##{action}, #{verb} #{path})"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def path_param_schema(param)
|
|
84
|
+
{ anyOf: [{ type: "string" }, { type: "integer" }], description: "The :#{param} segment of #{path}" }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module McpOnRuby
|
|
4
|
+
# Renders the exposed tools as a table for `bin/rails mcp:tools`.
|
|
5
|
+
module ToolTable
|
|
6
|
+
HEADERS = %w[TOOL ACTION VERB PATH ACCESS].freeze
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def render(specs, configuration: McpOnRuby.configuration)
|
|
11
|
+
return "No tools are exposed. Add `mcp_tool` declarations to your controllers.\n" if specs.empty?
|
|
12
|
+
|
|
13
|
+
rows = specs.map do |spec|
|
|
14
|
+
[spec.name, "#{spec.controller.name}##{spec.action}", spec.verb, spec.path, access(spec, configuration)]
|
|
15
|
+
end
|
|
16
|
+
widths = HEADERS.each_index.map { |index| ([HEADERS] + rows).map { |row| row[index].length }.max }
|
|
17
|
+
|
|
18
|
+
([HEADERS] + rows).map { |row| format_row(row, widths) }.join("\n") << "\n"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def access(spec, configuration)
|
|
22
|
+
return "read-only" unless spec.write?
|
|
23
|
+
|
|
24
|
+
configuration.allow_writes ? "write" : "write (disabled)"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def format_row(row, widths)
|
|
28
|
+
row.each_with_index.map { |cell, index| cell.ljust(widths[index]) }.join(" ").rstrip
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|