rails_mcp_engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 53d474e90f159408325d95bf24d519e93a5f82b2b00258dc756300dd6ae04365
4
+ data.tar.gz: f38095e894a2ae6b3aba6744aadfaf529d8aa9029f7f1b0f0c4992516e2beb93
5
+ SHA512:
6
+ metadata.gz: 67040a2d30ec66489b8dd99f7d995f7f93e36f353e935384d8205b51d8d4103438f3ea83f8ead1397ef8afd0f54b823f76f513f3d5fe4613a0316de380d8b5a4
7
+ data.tar.gz: 75e1d06bb502e402beb4f8aa85c5ede2ab741017526cd7ed413130ab6c12edeb20a77ce3fdb7cccaf859103de82d24389d029a041271b90bf79fa8938835217b
data/README.md ADDED
@@ -0,0 +1,134 @@
1
+ # Rails MCP Engine
2
+
3
+ Rails MCP Engine provides a unified tool-definition pipeline for Rails 8 applications. Service classes declare Sorbet-typed signatures and metadata once, and the engine auto-generates both RubyLLM and FastMCP tool classes at boot.
4
+
5
+ ## Core Dependencies
6
+
7
+ This engine is built on top of these powerful libraries:
8
+
9
+ - **[RubyLLM](https://github.com/crmne/ruby_llm)**: For generating LLM-compatible tool definitions.
10
+ - **[FastMCP](https://github.com/yjacquin/fast-mcp)**: For creating Model Context Protocol (MCP) servers.
11
+ - **[Sorbet](https://github.com/sorbet/sorbet)**: For static type checking and signature definitions.
12
+
13
+
14
+ ## Installation
15
+
16
+ Add this line to your application's Gemfile:
17
+
18
+ ```ruby
19
+ gem 'rails_mcp_engine'
20
+ ```
21
+
22
+ And then execute:
23
+
24
+ ```bash
25
+ bundle install
26
+ ```
27
+
28
+ ## How it works
29
+
30
+ - **Service classes** live under the `Tools::` namespace, extend `ToolMeta`, and expose a single Sorbet-signed entrypoint (defaults to `#call`).
31
+ - **ToolMeta DSL** captures names, descriptions, and parameter metadata, while Sorbet signatures provide the type source of truth.
32
+ - The **ToolSchema pipeline** merges Sorbet type information with metadata, producing a unified schema AST.
33
+ - **Factories** (`ToolSchema::RubyLlmFactory` and `ToolSchema::FastMcpFactory`) transform the AST into RubyLLM tools and FastMCP `ApplicationTool` subclasses, respectively.
34
+ - The **Engine** automatically iterates through registered service classes and generates both tool types during Rails initialization (`to_prepare`).
35
+
36
+ ## Defining a tool service
37
+
38
+ Create a service that extends `ToolMeta` and uses Sorbet for the entrypoint signature. Only business logic belongs here; tool wrappers are generated.
39
+
40
+ ```ruby
41
+ # app/services/tools/book_meeting_service.rb
42
+ class Tools::BookMeetingService
43
+ extend T::Sig
44
+ extend ToolMeta
45
+
46
+ tool_name "book_meeting"
47
+ tool_description "Books a meeting."
48
+ tool_param :window, description: "Start/finish window"
49
+ tool_param :participants, description: "Email recipients"
50
+
51
+ sig do
52
+ params(
53
+ window: T::Hash[Symbol, String],
54
+ participants: T::Array[String]
55
+ ).returns(T::Hash[Symbol, T.untyped])
56
+ end
57
+ def call(window:, participants:)
58
+ # ... business logic ...
59
+ end
60
+ end
61
+ ```
62
+
63
+ On boot, the engine generates:
64
+ - `Tools::BookMeeting < RubyLLM::Tool` with a matching `params` block.
65
+ - `Mcp::BookMeetingTool < ApplicationTool` with a matching `arguments` block.
66
+
67
+ ## Default meta tool
68
+
69
+ `Tools::MetaToolService` is included by default to explore and execute registered tools at runtime. It exposes a single `action` argument with supporting keywords:
70
+
71
+ - `list`: return full tool details (name, description, params, return type).
72
+ - `list_summary`: return only names and descriptions.
73
+ - `search`: provide `query` to fuzzy-match name/description.
74
+ - `get`: provide `tool_name` to fetch a full schema payload.
75
+ - `run`: provide `tool_name` and `arguments` to invoke a tool through its service class.
76
+
77
+ > **Note:** The `register` action is not available via the tool interface for security reasons. Developers can manually register tools using `Tools::MetaToolService.new.register_tool("ClassName")` in their code.
78
+
79
+ Example invocation from a console:
80
+
81
+ ```ruby
82
+ Tools::MetaToolService.new.call(action: 'run', tool_name: 'book_meeting', arguments: { window: { start: '...', finish: '...' }, participants: ['a@example.com'] })
83
+ ```
84
+
85
+ ## Tool Registration Hooks
86
+
87
+ You can attach `before_call` and `after_call` hooks when manually registering tools. These hooks are useful for logging, tracing, or other side effects.
88
+
89
+ ```ruby
90
+ Tools::MetaToolService.new.register_tool(
91
+ 'Tools::BookMeetingService',
92
+ before_call: ->(args) { Rails.logger.info("Calling tool with #{args}") },
93
+ after_call: ->(result) { Rails.logger.info("Tool returned #{result}") }
94
+ )
95
+ ```
96
+
97
+ - `before_call`: A `Proc` that receives the arguments hash.
98
+ - `after_call`: A `Proc` that receives the result.
99
+
100
+ These hooks are executed around the tool's entrypoint method for both RubyLLM and FastMCP wrappers.
101
+
102
+ ## Development
103
+
104
+ After checking out the repo, run `bundle install` to install dependencies. Then, run `bundle exec rails test` to run the tests.
105
+
106
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
107
+
108
+ ### Playground & Chat
109
+
110
+ The engine includes a built-in playground and chat interface for testing your tools.
111
+
112
+ 1. **Mount the engine**: Ensure the engine is mounted in your `config/routes.rb` (e.g., `mount RailsMcpEngine::Engine => '/rails_mcp_engine'`).
113
+ 2. **Access the Playground**: Navigate to `/rails_mcp_engine/playground` to register and test tools individually.
114
+ 3. **Access the Chat**: Navigate to `/rails_mcp_engine/chat` to test tools within a conversational interface using OpenAI models.
115
+
116
+ The playground allows you to:
117
+ - Register tool services dynamically by pasting Ruby code.
118
+ - Run registered tools with JSON arguments.
119
+ - View tool schemas and details.
120
+
121
+ The chat interface allows you to:
122
+ - Chat with OpenAI models (e.g., gpt-4o).
123
+ - Automatically invoke registered tools during the conversation.
124
+ - View tool calls and results within the chat history.
125
+
126
+ Screenshots:
127
+
128
+ | Playground | Chat |
129
+ | --- | --- |
130
+ | ![Playground](docs/rails_gem_engine_screenshot_tools.png) | ![Chat](docs/rails_gem_engine_screenshot_chat.png) |
131
+
132
+ ## License
133
+
134
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << 'test'
6
+ t.pattern = 'test/**/*_test.rb'
7
+ t.warning = false
8
+ end
9
+
10
+ task default: :test
@@ -0,0 +1,5 @@
1
+ module RailsMcpEngine
2
+ class ApplicationController < ::ApplicationController
3
+ include RailsMcpEngine::Engine.routes.url_helpers
4
+ end
5
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module RailsMcpEngine
6
+ class ChatController < ApplicationController
7
+ def show
8
+ @tools = schemas
9
+ @models = [
10
+ # OpenAI
11
+ 'gpt-5-nano',
12
+ 'gpt-4.1',
13
+ 'gpt-4o',
14
+ 'gpt-4o-mini',
15
+ # Google
16
+ 'gemini-2.5-pro',
17
+ 'gemini-2.0-pro-exp',
18
+ # Anthropic
19
+ 'claude-sonnet-4-5',
20
+ 'claude-3-7-sonnet-20250219',
21
+ 'claude-3-5-haiku-20241022',
22
+ 'claude-3-haiku-20240307'
23
+ ]
24
+ end
25
+
26
+ def send_message
27
+ user_message = params[:message].to_s
28
+ model = params[:model].to_s
29
+ conversation_history = JSON.parse(params[:conversation_history] || '[]', symbolize_names: true)
30
+
31
+ if user_message.strip.empty?
32
+ render json: { error: 'Message is required' }, status: :bad_request
33
+ return
34
+ end
35
+
36
+ provider = if model.start_with?('gemini')
37
+ :gemini
38
+ elsif model.start_with?('claude')
39
+ :anthropic
40
+ else
41
+ :openai
42
+ end
43
+
44
+ # Create RubyLLM chat instance with configuration
45
+ chat = RubyLLM.chat(
46
+ provider: provider,
47
+ model: model
48
+ )
49
+
50
+ # Register all available tools
51
+ tool_classes = get_tool_classes
52
+ chat = tool_classes.reduce(chat) { |c, tool_class| c.with_tool(tool_class) }
53
+
54
+ # Prepare the message with conversation history context
55
+ if conversation_history.empty?
56
+ # First message: just send as-is
57
+ full_message = user_message
58
+ else
59
+ # Include conversation history for context
60
+ context_parts = ['Previous conversation:']
61
+ conversation_history.each do |msg|
62
+ role_label = msg[:role] == 'user' ? 'User' : 'Assistant'
63
+ context_parts << "#{role_label}: #{msg[:content]}"
64
+ end
65
+ context_parts << "\nCurrent question:"
66
+ context_parts << user_message
67
+ full_message = context_parts.join("\n\n")
68
+ end
69
+
70
+ # Ask the question and capture response
71
+ begin
72
+ # Ruby LLM handles tool calling automatically
73
+ response = chat.ask(full_message)
74
+ assistant_content = response.content
75
+
76
+ # Build conversation history manually since Ruby LLM manages it internally
77
+ # Add user message
78
+ conversation_history << { role: 'user', content: user_message }
79
+ # Add assistant response
80
+ conversation_history << { role: 'assistant', content: assistant_content }
81
+
82
+ # Tool results are handled transparently by Ruby LLM
83
+ # We don't have direct access to them, so return empty array
84
+ tool_results = []
85
+
86
+ render json: {
87
+ conversation_history: conversation_history,
88
+ tool_results: tool_results
89
+ }
90
+ rescue StandardError => e
91
+ render json: { error: e.message }, status: :bad_request
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ def schemas
98
+ ToolMeta.registry.map { |service_class| ToolSchema::Builder.build(service_class) }
99
+ end
100
+
101
+ def get_tool_classes
102
+ # Get all RubyLLM tool classes that were generated
103
+ schemas.map do |schema|
104
+ tool_constant = ToolSchema::RubyLlmFactory.tool_class_name(schema[:service_class])
105
+ # Accessing Tools from global namespace
106
+ ::Tools.const_get(tool_constant)
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module RailsMcpEngine
6
+ class PlaygroundController < ApplicationController
7
+ def show
8
+ @register_result = flash[:register_result]
9
+ @test_result = flash[:test_result]
10
+ @tools = schemas
11
+ end
12
+
13
+ def register
14
+ source = params[:source].to_s
15
+ class_name = extract_class_name(source)
16
+
17
+ result = if source.strip.empty?
18
+ { error: 'Tool source code is required' }
19
+ elsif class_name.nil?
20
+ { error: 'Could not infer class name from the provided source' }
21
+ else
22
+ register_source(source, class_name)
23
+ end
24
+
25
+ flash[:register_result] = result
26
+ redirect_to playground_path
27
+ end
28
+
29
+ def run
30
+ tool_name = params[:tool_name].to_s
31
+ parsed_arguments = parse_arguments(params[:arguments])
32
+ schema = schemas.find { |s| s[:name] == tool_name }
33
+
34
+ result = if schema.nil?
35
+ { error: "Tool not found: #{tool_name}" }
36
+ elsif parsed_arguments.is_a?(String)
37
+ { error: parsed_arguments }
38
+ else
39
+ invoke_tool(schema, parsed_arguments)
40
+ end
41
+
42
+ flash[:test_result] = result
43
+ redirect_to playground_path
44
+ end
45
+
46
+ def delete_tool
47
+ tool_name = params[:tool_name].to_s
48
+ schema = schemas.find { |s| s[:name] == tool_name }
49
+
50
+ result = if schema.nil?
51
+ { error: "Tool not found: #{tool_name}" }
52
+ else
53
+ delete_tool_from_registry(schema[:service_class])
54
+ end
55
+
56
+ flash[:register_result] = result
57
+ redirect_to playground_path
58
+ end
59
+
60
+ private
61
+
62
+ def schemas
63
+ ToolMeta.registry.map { |service_class| ToolSchema::Builder.build(service_class) }
64
+ end
65
+
66
+ def extract_class_name(source)
67
+ require 'ripper'
68
+ sexp = Ripper.sexp(source)
69
+ return nil unless sexp
70
+
71
+ # sexp is [:program, statements]
72
+ statements = sexp[1]
73
+ find_class(statements, [])
74
+ end
75
+
76
+ def find_class(statements, namespace)
77
+ return nil unless statements.is_a?(Array)
78
+
79
+ statements.each do |stmt|
80
+ next unless stmt.is_a?(Array)
81
+
82
+ case stmt.first
83
+ when :module
84
+ # [:module, const_ref, body]
85
+ # body is [:bodystmt, statements, ...]
86
+ const_node = stmt[1]
87
+ const_name = get_const_name(const_node)
88
+
89
+ body_stmt = stmt[2]
90
+ inner_statements = body_stmt[1]
91
+
92
+ result = find_class(inner_statements, namespace + [const_name])
93
+ return result if result
94
+ when :class
95
+ # [:class, const_ref, superclass, body]
96
+ const_node = stmt[1]
97
+ const_name = get_const_name(const_node)
98
+
99
+ return (namespace + [const_name]).join('::')
100
+ end
101
+ end
102
+ nil
103
+ end
104
+
105
+ def get_const_name(node)
106
+ return nil unless node.is_a?(Array)
107
+
108
+ type = node.first
109
+ if type == :const_ref
110
+ # [:const_ref, [:@const, "Name", ...]]
111
+ node[1][1]
112
+ elsif type == :const_path_ref
113
+ # [:const_path_ref, parent, child]
114
+ parent = node[1]
115
+ child = node[2] # [:@const, "Name", ...]
116
+
117
+ parent_name = if parent.first == :var_ref
118
+ parent[1][1]
119
+ else
120
+ get_const_name(parent)
121
+ end
122
+
123
+ "#{parent_name}::#{child[1]}"
124
+ else
125
+ nil
126
+ end
127
+ end
128
+
129
+ def register_source(source, class_name)
130
+ Object.class_eval(source)
131
+ # Use the engine's namespace or ensure Tools is available.
132
+ # Assuming Tools module is defined in the host app or globally.
133
+ # If Tools is not defined, we might need to define it or use a different namespace.
134
+ # For now, keeping it as is, assuming host app environment.
135
+
136
+ # However, since we are in an engine, we should check if we need to be more careful.
137
+ # The original code used Tools::MetaToolService.
138
+ # Let's check if Tools is defined in the engine or expected from host.
139
+ # The engine.rb defines ApplicationTool.
140
+
141
+ # Re-using the logic from ManualController but adapting for Engine.
142
+ ::Tools::MetaToolService.new.register_tool(
143
+ class_name,
144
+ before_call: ->(args) { Rails.logger.info(" [MCP] Request #{class_name}: #{args.inspect}") },
145
+ after_call: ->(result) { Rails.logger.info(" [MCP] Response #{class_name}: #{result.inspect}") }
146
+ )
147
+ rescue StandardError => e
148
+ { error: e.message }
149
+ end
150
+
151
+ def parse_arguments(raw_value)
152
+ return {} if raw_value.to_s.strip.empty?
153
+
154
+ JSON.parse(raw_value, symbolize_names: true)
155
+ rescue JSON::ParserError => e
156
+ e.message
157
+ end
158
+
159
+ def invoke_tool(schema, arguments)
160
+ tool_constant = ToolSchema::RubyLlmFactory.tool_class_name(schema[:service_class])
161
+ # Accessing Tools from global namespace
162
+ tool_class = ::Tools.const_get(tool_constant)
163
+ result = tool_class.new.execute(**arguments.symbolize_keys)
164
+
165
+ { tool: { name: schema[:name], description: schema[:description] }, result: result }
166
+ rescue StandardError => e
167
+ Rails.logger.error("Error invoking tool #{schema[:name]}: #{e.message}")
168
+ { error: e.message }
169
+ end
170
+
171
+ def delete_tool_from_registry(service_class)
172
+ ToolMeta.registry.delete(service_class)
173
+
174
+ # Also remove the RubyLLM tool class constant
175
+ tool_constant = ToolSchema::RubyLlmFactory.tool_class_name(service_class)
176
+ ::Tools.send(:remove_const, tool_constant) if ::Tools.const_defined?(tool_constant, false)
177
+
178
+ { success: 'Tool deleted successfully' }
179
+ rescue StandardError => e
180
+ { error: e.message }
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,91 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+
6
+ # ToolMeta defines a DSL for annotating service classes that represent tools.
7
+ # The metadata collected here becomes the source of truth for non-type
8
+ # attributes such as names, descriptions, and examples.
9
+ module ToolMeta
10
+ extend T::Sig
11
+
12
+ ParamMetadata = T.type_alias do
13
+ T::Hash[Symbol, T.untyped]
14
+ end
15
+
16
+ class MissingSignatureError < StandardError; end
17
+
18
+ sig { params(base: T.class_of(Object)).void }
19
+ def self.extended(base)
20
+ registry << base unless registry.include?(base)
21
+ base.instance_variable_set(:@tool_params, T.let([], T::Array[ParamMetadata]))
22
+ base.instance_variable_set(:@tool_name, nil)
23
+ base.instance_variable_set(:@tool_description, nil)
24
+ end
25
+
26
+ sig { returns(T::Array[T.class_of(Object)]) }
27
+ def self.registry
28
+ @registry ||= []
29
+ end
30
+
31
+ sig { void }
32
+ def self.clear_registry
33
+ @registry = []
34
+ end
35
+
36
+ sig { params(name: String).void }
37
+ def tool_name(name)
38
+ @tool_name = name
39
+ end
40
+
41
+ sig { params(description: String).void }
42
+ def tool_description(description)
43
+ @tool_description = description
44
+ end
45
+
46
+ sig do
47
+ params(
48
+ name: T.any(String, Symbol),
49
+ description: T.nilable(String),
50
+ required: T::Boolean,
51
+ example: T.untyped,
52
+ enum: T.nilable(T::Array[T.untyped])
53
+ ).void
54
+ end
55
+ def tool_param(name, description: nil, required: true, example: nil, enum: nil)
56
+ @tool_params << {
57
+ name: name.to_sym,
58
+ description: description,
59
+ required: required,
60
+ example: example,
61
+ enum: enum
62
+ }
63
+ end
64
+
65
+ sig { returns(String) }
66
+ def tool_entrypoint
67
+ 'call'
68
+ end
69
+
70
+ sig { returns(T::Hash[Symbol, T.untyped]) }
71
+ def tool_metadata
72
+ {
73
+ name: @tool_name || default_tool_name,
74
+ description: @tool_description || '',
75
+ params: @tool_params,
76
+ entrypoint: tool_entrypoint
77
+ }
78
+ end
79
+
80
+ private
81
+
82
+ sig { returns(String) }
83
+ def default_tool_name
84
+ return '' unless respond_to?(:name) && name
85
+
86
+ name.split('::').last
87
+ .gsub(/Service$/, '')
88
+ .gsub(/([a-z0-9])([A-Z])/, '\\1_\\2')
89
+ .downcase
90
+ end
91
+ end
@@ -0,0 +1,71 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+ require_relative '../tool_meta'
6
+ require_relative 'sorbet_type_mapper'
7
+
8
+ module ToolSchema
9
+ class Builder
10
+ extend T::Sig
11
+
12
+ SchemaAst = T.type_alias do
13
+ T::Hash[Symbol, T.untyped]
14
+ end
15
+
16
+ sig { params(service_class: T.class_of(Object)).returns(SchemaAst) }
17
+ def self.build(service_class)
18
+ metadata = T.let(service_class.tool_metadata, T::Hash[Symbol, T.untyped])
19
+ entrypoint = metadata[:entrypoint]&.to_sym || :call
20
+ method = service_class.instance_method(entrypoint)
21
+ type_info = SorbetTypeMapper.map_signature(method)
22
+
23
+ params_ast = type_info[:params].map do |param_ast|
24
+ merge_param(param_ast, metadata[:params])
25
+ end
26
+
27
+ {
28
+ name: metadata[:name],
29
+ description: metadata[:description],
30
+ params: params_ast,
31
+ return_type: type_info[:return_type],
32
+ entrypoint: entrypoint,
33
+ service_class: service_class
34
+ }
35
+ end
36
+
37
+ sig do
38
+ params(
39
+ param_ast: T::Hash[Symbol, T.untyped],
40
+ metadata_params: T.nilable(T::Array[T::Hash[Symbol, T.untyped]])
41
+ ).returns(T::Hash[Symbol, T.untyped])
42
+ end
43
+ def self.merge_param(param_ast, metadata_params)
44
+ meta = metadata_params&.find { |p| p[:name].to_sym == param_ast[:name].to_sym }
45
+ description = meta ? meta[:description] : nil
46
+ example = meta ? meta[:example] : nil
47
+ enum = meta ? meta[:enum] : nil
48
+ required_from_meta = meta.nil? ? true : (meta[:required].nil? ? true : meta[:required])
49
+ required = param_ast[:required] && required_from_meta
50
+
51
+ param_ast.merge(
52
+ description: description,
53
+ example: example,
54
+ enum: enum,
55
+ required: required,
56
+ children: merge_children(param_ast[:children], metadata_params)
57
+ )
58
+ end
59
+
60
+ sig do
61
+ params(children: T.untyped, metadata_params: T.nilable(T::Array[T::Hash[Symbol, T.untyped]])).returns(T.untyped)
62
+ end
63
+ def self.merge_children(children, metadata_params)
64
+ return [] unless children.is_a?(Array)
65
+
66
+ children.map do |child|
67
+ merge_param(child, metadata_params)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,66 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+
6
+ module ToolSchema
7
+ module FastMcpBuilder
8
+ extend T::Sig
9
+
10
+ sig { params(params_ast: T::Array[T::Hash[Symbol, T.untyped]]).returns(Proc) }
11
+ def self.arguments_block(params_ast)
12
+ proc do
13
+ params_ast.each do |param|
14
+ FastMcpBuilder.build_param(self, param)
15
+ end
16
+ end
17
+ end
18
+
19
+ sig { params(ctx: BasicObject, param: T::Hash[Symbol, T.untyped]).void }
20
+ def self.build_param(ctx, param)
21
+ wrapper = param[:required] ? ctx.required(param[:name]) : ctx.optional(param[:name])
22
+
23
+ node = case param[:type]
24
+ when :object
25
+ wrapper.hash do
26
+ (param[:children] || []).each do |child|
27
+ FastMcpBuilder.build_param(self, child)
28
+ end
29
+ end
30
+ when :array
31
+ item = param[:item_type]
32
+ if item && scalar_type?(item[:type])
33
+ wrapper.array(scalar_symbol(item[:type]))
34
+ elsif item&.dig(:type) == :object
35
+ wrapper.array(:hash) do
36
+ (item[:children] || []).each do |child|
37
+ FastMcpBuilder.build_param(self, child)
38
+ end
39
+ end
40
+ else
41
+ wrapper.array(:any)
42
+ end
43
+ else
44
+ wrapper.value(scalar_symbol(param[:type]))
45
+ end
46
+
47
+ node.description(param[:description]) if param[:description]
48
+ end
49
+
50
+ sig { params(type: T.untyped).returns(T::Boolean) }
51
+ def self.scalar_type?(type)
52
+ %i[string integer float boolean any].include?(type)
53
+ end
54
+
55
+ sig { params(type: T.untyped).returns(Symbol) }
56
+ def self.scalar_symbol(type)
57
+ case type
58
+ when :string then :string
59
+ when :integer then :integer
60
+ when :float then :float
61
+ when :boolean then :bool
62
+ else :any
63
+ end
64
+ end
65
+ end
66
+ end