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,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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp_on_ruby"
4
+ require "mcp_on_ruby/test_helper"
5
+
6
+ RSpec.configure do |config|
7
+ config.include McpOnRuby::TestHelper
8
+ 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