diff --git a/Gemfile.lock b/Gemfile.lock index 5e0da89..72b8334 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,29 +10,31 @@ PATH GEM remote: https://rubygems.org/ specs: - actioncable (8.0.4) - actionpack (= 8.0.4) - activesupport (= 8.0.4) + action_text-trix (2.1.15) + railties + actioncable (8.1.1) + actionpack (= 8.1.1) + activesupport (= 8.1.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.0.4) - actionpack (= 8.0.4) - activejob (= 8.0.4) - activerecord (= 8.0.4) - activestorage (= 8.0.4) - activesupport (= 8.0.4) + actionmailbox (8.1.1) + actionpack (= 8.1.1) + activejob (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) mail (>= 2.8.0) - actionmailer (8.0.4) - actionpack (= 8.0.4) - actionview (= 8.0.4) - activejob (= 8.0.4) - activesupport (= 8.0.4) + actionmailer (8.1.1) + actionpack (= 8.1.1) + actionview (= 8.1.1) + activejob (= 8.1.1) + activesupport (= 8.1.1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.0.4) - actionview (= 8.0.4) - activesupport (= 8.0.4) + actionpack (8.1.1) + actionview (= 8.1.1) + activesupport (= 8.1.1) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -40,52 +42,52 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.0.4) - actionpack (= 8.0.4) - activerecord (= 8.0.4) - activestorage (= 8.0.4) - activesupport (= 8.0.4) + actiontext (8.1.1) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.0.4) - activesupport (= 8.0.4) + actionview (8.1.1) + activesupport (= 8.1.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (8.0.4) - activesupport (= 8.0.4) + activejob (8.1.1) + activesupport (= 8.1.1) globalid (>= 0.3.6) - activemodel (8.0.4) - activesupport (= 8.0.4) - activerecord (8.0.4) - activemodel (= 8.0.4) - activesupport (= 8.0.4) + activemodel (8.1.1) + activesupport (= 8.1.1) + activerecord (8.1.1) + activemodel (= 8.1.1) + activesupport (= 8.1.1) timeout (>= 0.4.0) - activestorage (8.0.4) - actionpack (= 8.0.4) - activejob (= 8.0.4) - activerecord (= 8.0.4) - activesupport (= 8.0.4) + activestorage (8.1.1) + actionpack (= 8.1.1) + activejob (= 8.1.1) + activerecord (= 8.1.1) + activesupport (= 8.1.1) marcel (~> 1.0) - activesupport (8.0.4) + activesupport (8.1.1) base64 - benchmark (>= 0.3) bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + json logger (>= 1.4.2) minitest (>= 5.1) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) - addressable (2.8.8) - public_suffix (>= 2.0.2, < 8.0) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) ast (2.4.3) base64 (0.3.0) - benchmark (0.5.0) bigdecimal (3.3.1) builder (3.3.0) capybara (3.40.0) @@ -131,7 +133,7 @@ GEM dry-inflector (~> 1.0) dry-logic (~> 1.4) zeitwerk (~> 2.6) - erb (6.0.0) + erb (5.1.3) erubi (1.13.1) event_stream_parser (1.0.0) faraday (2.14.0) @@ -174,7 +176,7 @@ GEM net-pop net-smtp marcel (1.1.0) - matrix (0.4.3) + matrix (0.4.2) mime-types (3.7.0) logger mime-types-data (~> 3.2025, >= 3.2025.0507) @@ -193,13 +195,11 @@ GEM timeout net-smtp (0.5.1) net-protocol - nio4r (2.7.5) + nio4r (2.7.4) nokogiri (1.18.10-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.10-x86_64-linux-gnu) - racc (~> 1.4) parallel (1.27.0) - parser (3.3.10.0) + parser (3.3.8.0) ast (~> 2.4.1) racc pp (0.6.3) @@ -209,7 +209,7 @@ GEM psych (5.2.6) date stringio - public_suffix (7.0.0) + public_suffix (6.0.2) puma (7.1.0) nio4r (~> 2.0) racc (1.8.1) @@ -221,20 +221,20 @@ GEM rack (>= 1.3) rackup (2.2.1) rack (>= 3) - rails (8.0.4) - actioncable (= 8.0.4) - actionmailbox (= 8.0.4) - actionmailer (= 8.0.4) - actionpack (= 8.0.4) - actiontext (= 8.0.4) - actionview (= 8.0.4) - activejob (= 8.0.4) - activemodel (= 8.0.4) - activerecord (= 8.0.4) - activestorage (= 8.0.4) - activesupport (= 8.0.4) + rails (8.1.1) + actioncable (= 8.1.1) + actionmailbox (= 8.1.1) + actionmailer (= 8.1.1) + actionpack (= 8.1.1) + actiontext (= 8.1.1) + actionview (= 8.1.1) + activejob (= 8.1.1) + activemodel (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) bundler (>= 1.15.0) - railties (= 8.0.4) + railties (= 8.1.1) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest @@ -242,9 +242,9 @@ GEM rails-html-sanitizer (1.6.2) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - railties (8.0.4) - actionpack (= 8.0.4) - activesupport (= 8.0.4) + railties (8.1.1) + actionpack (= 8.1.1) + activesupport (= 8.1.1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -253,15 +253,15 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.3.1) - rdoc (6.16.0) + rdoc (6.15.1) erb psych (>= 4.0.0) tsort - regexp_parser (2.11.3) - reline (0.6.3) + regexp_parser (2.10.0) + reline (0.6.2) io-console (~> 0.5) rexml (3.4.4) - rubocop (1.81.7) + rubocop (1.75.7) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -269,10 +269,10 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.47.1, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.48.0) + rubocop-ast (1.44.1) parser (>= 3.3.7.2) prism (~> 1.4) ruby-progressbar (1.13.0) @@ -286,8 +286,8 @@ GEM marcel (~> 1.0) ruby_llm-schema (~> 0.2.1) zeitwerk (~> 2) - ruby_llm-schema (0.2.5) - rubyzip (3.2.2) + ruby_llm-schema (0.2.1) + rubyzip (3.2.1) securerandom (0.4.1) selenium-webdriver (4.38.0) base64 (~> 0.2) @@ -295,16 +295,16 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) - sorbet-runtime (0.6.12793) - stringio (3.1.8) + sorbet-runtime (0.6.12798) + stringio (3.1.7) thor (1.4.0) timeout (0.4.4) tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (3.2.0) - unicode-emoji (~> 4.1) - unicode-emoji (4.1.0) + unicode-display_width (3.1.4) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) uri (1.1.1) useragent (0.16.11) websocket (1.2.11) @@ -317,7 +317,7 @@ GEM zeitwerk (2.7.3) PLATFORMS - arm64-darwin-24 + arm64-darwin x86_64-linux DEPENDENCIES diff --git a/app/controllers/rails_mcp_engine/playground_controller.rb b/app/controllers/rails_mcp_engine/playground_controller.rb index 7275c8e..5547845 100644 --- a/app/controllers/rails_mcp_engine/playground_controller.rb +++ b/app/controllers/rails_mcp_engine/playground_controller.rb @@ -50,7 +50,7 @@ def delete_tool result = if schema.nil? { error: "Tool not found: #{tool_name}" } else - delete_tool_from_registry(schema[:service_class]) + delete_tool_from_registry(tool_name) end flash[:register_result] = result @@ -139,8 +139,9 @@ def register_source(source, class_name) # The engine.rb defines ApplicationTool. # Re-using the logic from ManualController but adapting for Engine. - ::Tools::MetaToolService.new.register_tool( + ::Tools::MetaToolWriteService.new.register_tool( class_name, + source: source, before_call: ->(args) { Rails.logger.info(" [MCP] Request #{class_name}: #{args.inspect}") }, after_call: ->(result) { Rails.logger.info(" [MCP] Response #{class_name}: #{result.inspect}") } ) @@ -168,14 +169,8 @@ def invoke_tool(schema, arguments) { error: e.message } end - def delete_tool_from_registry(service_class) - ToolMeta.registry.delete(service_class) - - # Also remove the RubyLLM tool class constant - tool_constant = ToolSchema::RubyLlmFactory.tool_class_name(service_class) - ::Tools.send(:remove_const, tool_constant) if ::Tools.const_defined?(tool_constant, false) - - { success: 'Tool deleted successfully' } + def delete_tool_from_registry(tool_name) + ::Tools::MetaToolWriteService.new.delete_tool(tool_name) rescue StandardError => e { error: e.message } end diff --git a/app/services/tools/meta_tool_service.rb b/app/services/tools/meta_tool_service.rb index a2afa00..e6352a3 100644 --- a/app/services/tools/meta_tool_service.rb +++ b/app/services/tools/meta_tool_service.rb @@ -44,26 +44,7 @@ def call(action:, tool_name: nil, query: nil, arguments: nil) end end - sig { params(class_name: T.nilable(String), before_call: T.nilable(Proc), after_call: T.nilable(Proc)).returns(T::Hash[Symbol, T.untyped]) } - def register_tool(class_name, before_call: nil, after_call: nil) - return { error: 'class_name is required for register' } if class_name.nil? || class_name.empty? - service_class = constantize(class_name) - return { error: "Could not find #{class_name}" } if service_class.nil? - return { error: "#{class_name} must extend ToolMeta" } unless service_class.respond_to?(:tool_metadata) - - ToolMeta.registry << service_class unless ToolMeta.registry.include?(service_class) - - schema = ToolSchema::Builder.build(service_class) - ToolSchema::RubyLlmFactory.build(service_class, schema, before_call: before_call, after_call: after_call) - ToolSchema::FastMcpFactory.build(service_class, schema, before_call: before_call, after_call: after_call) - - { status: 'registered', tool: summary_payload(schema) } - rescue ToolMeta::MissingSignatureError => e - { error: e.message } - rescue NameError => e - { error: "Could not find #{class_name}: #{e.message}" } - end sig { params(tool_names: T::Array[String]).returns(T::Array[T.class_of(Object)]) } def self.ruby_llm_tools(tool_names) @@ -76,7 +57,6 @@ def self.ruby_llm_tools(tool_names) end end - private sig { params(query: T.nilable(String)).returns(T::Hash[Symbol, T.untyped]) } def search_tools(query) diff --git a/app/services/tools/meta_tool_write_service.rb b/app/services/tools/meta_tool_write_service.rb new file mode 100644 index 0000000..f9715b8 --- /dev/null +++ b/app/services/tools/meta_tool_write_service.rb @@ -0,0 +1,72 @@ +# typed: strict +# frozen_string_literal: true + +require 'sorbet-runtime' +require 'tool_meta' +require 'tool_schema/builder' +require 'tool_schema/ruby_llm_factory' +require 'tool_schema/fast_mcp_factory' + +module Tools + class MetaToolWriteService + extend T::Sig + + sig { params(class_name: T.nilable(String), source: T.nilable(String), before_call: T.nilable(Proc), after_call: T.nilable(Proc)).returns(T::Hash[Symbol, T.untyped]) } + def register_tool(class_name, source: nil, before_call: nil, after_call: nil) + return { error: 'class_name is required for register' } if class_name.nil? || class_name.empty? + + # If source is provided, evaluate it first + if source + begin + Object.class_eval(source) + rescue StandardError => e + return { error: "Failed to evaluate source: #{e.message}" } + end + end + + begin + service_class = meta_service.constantize(class_name) + rescue NameError + return { error: "Could not find #{class_name}" } + end + + return { error: "#{class_name} must extend ToolMeta" } unless service_class.respond_to?(:tool_metadata) + + ToolMeta.registry << service_class unless ToolMeta.registry.include?(service_class) + + schema = ToolSchema::Builder.build(service_class) + ToolSchema::RubyLlmFactory.build(service_class, schema, before_call: before_call, after_call: after_call) + ToolSchema::FastMcpFactory.build(service_class, schema, before_call: before_call, after_call: after_call) + + { status: 'registered', tool: meta_service.summary_payload(schema) } + rescue ToolMeta::MissingSignatureError => e + { error: e.message } + rescue NameError => e + { error: "Could not find #{class_name}: #{e.message}" } + end + + sig { params(tool_name: String).returns(T::Hash[Symbol, T.untyped]) } + def delete_tool(tool_name) + schema = meta_service.find_schema(tool_name) + return { error: "Tool not found: #{tool_name}" } unless schema + + service_class = schema[:service_class] + ToolMeta.registry.delete(service_class) + + tool_constant = ToolSchema::RubyLlmFactory.tool_class_name(service_class) + Tools.send(:remove_const, tool_constant) if Tools.const_defined?(tool_constant, false) + fast_mcp_constant = ToolSchema::FastMcpFactory.tool_class_name(service_class) + Mcp.send(:remove_const, fast_mcp_constant) if Mcp.const_defined?(fast_mcp_constant, false) + + { success: 'Tool deleted successfully' } + rescue StandardError => e + { error: e.message } + end + + private + + def meta_service + @meta_service ||= Tools::MetaToolService.new + end + end +end diff --git a/test/meta_tool_service_test.rb b/test/meta_tool_service_test.rb index ddfd953..99ab632 100644 --- a/test/meta_tool_service_test.rb +++ b/test/meta_tool_service_test.rb @@ -2,6 +2,7 @@ require 'test_helper' require 'tools/meta_tool_service' +require 'tools/meta_tool_write_service' class MetaToolServiceTest < Minitest::Test def setup @@ -12,7 +13,7 @@ def setup def test_registers_service_and_builds_wrappers ToolMeta.clear_registry - result = meta_service.register_tool('Tools::SampleService') + result = write_service.register_tool('Tools::SampleService') assert_equal 'registered', result[:status] assert_includes ToolMeta.registry, Tools::SampleService @@ -69,7 +70,7 @@ def test_register_tool_with_hooks before_called = false after_called = false - meta_service.register_tool( + write_service.register_tool( 'Tools::SampleService', before_call: ->(_args) { before_called = true }, after_call: ->(_result) { after_called = true } @@ -88,7 +89,7 @@ def test_register_tool_with_hooks_ruby_llm before_called = false after_called = false - meta_service.register_tool( + write_service.register_tool( 'Tools::SampleService', before_call: ->(_args) { before_called = true }, after_call: ->(_result) { after_called = true } @@ -104,7 +105,7 @@ def test_register_tool_with_hooks_ruby_llm def test_ruby_llm_tools_returns_correct_tool_classes # Register a tool first - meta_service.register_tool('Tools::SampleService') + write_service.register_tool('Tools::SampleService') tools = Tools::MetaToolService.ruby_llm_tools(['sample']) assert_equal 1, tools.size @@ -120,7 +121,7 @@ def test_ruby_llm_tools_preserves_hooks ToolMeta.clear_registry before_called = false - meta_service.register_tool( + write_service.register_tool( 'Tools::SampleService', before_call: ->(_args) { before_called = true } ) @@ -141,6 +142,10 @@ def meta_service Tools::MetaToolService.new end + def write_service + Tools::MetaToolWriteService.new + end + def build_sample_service Tools.const_set(:SampleService, Class.new do extend T::Sig diff --git a/test/meta_tool_write_service_test.rb b/test/meta_tool_write_service_test.rb new file mode 100644 index 0000000..6f3048c --- /dev/null +++ b/test/meta_tool_write_service_test.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'tools/meta_tool_write_service' + +class MetaToolWriteServiceTest < Minitest::Test + def setup + ToolMeta.clear_registry + remove_constants + build_sample_service + end + + def test_registers_service_and_builds_wrappers + ToolMeta.clear_registry + result = write_service.register_tool('Tools::SampleService') + + assert_equal 'registered', result[:status] + assert_includes ToolMeta.registry, Tools::SampleService + + schema = ToolSchema::Builder.build(Tools::SampleService) + ruby_tool = ToolSchema::RubyLlmFactory.build(Tools::SampleService, schema) + fast_tool = ToolSchema::FastMcpFactory.build(Tools::SampleService, schema) + + assert_equal 'Say a friendly hello', ruby_tool.description(nil) + assert_equal 'Say a friendly hello', fast_tool.description(nil) + assert_equal 'sample', fast_tool.tool_name + end + + def test_register_tool_with_hooks + ToolMeta.clear_registry + + before_called = false + after_called = false + + write_service.register_tool( + 'Tools::SampleService', + before_call: ->(_args) { before_called = true }, + after_call: ->(_result) { after_called = true } + ) + + tool_class = Mcp::Sample + tool_class.new.call(name: 'Hooks') + + assert before_called, 'Before hook should be called' + assert after_called, 'After hook should be called' + end + + def test_delete_tool + write_service.register_tool('Tools::SampleService') + assert_includes ToolMeta.registry, Tools::SampleService + + result = write_service.delete_tool('sample') + assert_equal 'Tool deleted successfully', result[:success] + refute_includes ToolMeta.registry, Tools::SampleService + refute Tools.const_defined?(:Sample) + refute Mcp.const_defined?(:Sample) + end + + def test_delete_non_existent_tool + result = write_service.delete_tool('non_existent') + assert_equal 'Tool not found: non_existent', result[:error] + end + + private + + def write_service + Tools::MetaToolWriteService.new + end + + def build_sample_service + Tools.const_set(:SampleService, Class.new do + extend T::Sig + extend ToolMeta + + tool_name 'sample' + tool_description 'Say a friendly hello' + tool_param :name, description: 'Name to greet' + + sig { params(name: String).returns(String) } + def call(name:) + "Hello, #{name}!" + end + end) + end + + def remove_constants + Tools.send(:remove_const, :SampleService) if Tools.const_defined?(:SampleService, false) + Tools.send(:remove_const, :Sample) if Tools.const_defined?(:Sample, false) + end +end diff --git a/test_app/Gemfile.lock b/test_app/Gemfile.lock index 22df0d3..2c2528c 100644 --- a/test_app/Gemfile.lock +++ b/test_app/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: .. specs: - rails_mcp_engine (0.1.0) + rails_mcp_engine (0.2.0) fast-mcp (~> 1.6) rails (>= 7.1, < 8.2) ruby_llm (~> 1.9)