Class: Nexo::MCP::GatedTool

Inherits:
Object
  • Object
show all
Defined in:
lib/nexo/mcp/gated_tool.rb

Overview

Wraps a ruby_llm-mcp tool so the chat sees an identical tool but every invocation is authorized through Nexo::Permissions#authorize_mcp! first. A denied call returns { error: ... } (recoverable) and never raises into the loop — matching the sandbox-backed tools' authorize→act→rescue-+Denied+→ {error:} shape (see lib/nexo/tools/write_file.rb).

Everything except the execution method is delegated to the wrapped tool, so the chat cannot tell the wrapper apart from the raw MCP tool: it reports the same +name+/+description+/+params_schema+ and serializes identically.

VERIFY (Group 0, ruby_llm-mcp 1.0.0 + ruby_llm 1.16.0): RubyLLM::Chat#with_tool does not type-check — it accepts any object responding to #name — and the tool loop invokes tool.call(args) with a positional Hash. So a duck-typed wrapper is accepted; no RubyLLM::Tool subclass is required. #call here mirrors that entry point (a +RubyLLM::MCP::Tool+'s own #call normalizes and dispatches to #execute), and method_missing forwards +description+/+params_schema+/+to_h+/ etc. to the wrapped tool.

Instance Method Summary collapse

Constructor Details

#initialize(tool:, permissions:) ⇒ GatedTool

Wraps tool (a ruby_llm-mcp tool) so every call is authorized through permissions (the MCP axis) before delegating.



25
26
27
28
# File 'lib/nexo/mcp/gated_tool.rb', line 25

def initialize(tool:, permissions:)
  @tool = tool
  @permissions = permissions
end

Dynamic Method Handling

This class handles dynamic methods through the method_missing method

#method_missing(method_name, *args, **kwargs, &block) ⇒ Object

Delegates any unknown method to the wrapped tool (description, params_schema, to_h, ...), so the wrapper serializes identically. execute is handled explicitly above and never falls through here.



61
62
63
64
65
66
67
# File 'lib/nexo/mcp/gated_tool.rb', line 61

def method_missing(method_name, *args, **kwargs, &block)
  if @tool.respond_to?(method_name)
    @tool.public_send(method_name, *args, **kwargs, &block)
  else
    super
  end
end

Instance Method Details

#call(args = {}) ⇒ Object

Authorizes the call by tool name, then delegates to the wrapped tool. A denial is returned as { error: ... } so the model can recover — never raised into the loop.



33
34
35
36
37
38
# File 'lib/nexo/mcp/gated_tool.rb', line 33

def call(args = {})
  @permissions.authorize_mcp!(name, args)
  @tool.call(args)
rescue Nexo::Permissions::Denied => e
  {error: e.message}
end

#execute(*args, **kwargs) ⇒ Object

The gate lives on #call: #execute (the UNGATED tool body) must never be reachable through the wrapper, or a caller invoking #execute directly would skip authorization entirely. Route it back to the gated #call.



46
47
48
# File 'lib/nexo/mcp/gated_tool.rb', line 46

def execute(*args, **kwargs)
  call(kwargs.empty? ? (args.first || {}) : kwargs)
end

#nameObject

The wrapped tool's name — what the chat and the gate key on.



41
# File 'lib/nexo/mcp/gated_tool.rb', line 41

def name = @tool.name

#respond_to_missing?(method_name, include_private = false) ⇒ Boolean

Forward description/params_schema/to_h/etc. to the wrapped tool so the chat serializes it exactly like the raw MCP tool — but never execute (see above).

Returns:

  • (Boolean)


52
53
54
55
56
# File 'lib/nexo/mcp/gated_tool.rb', line 52

def respond_to_missing?(method_name, include_private = false)
  return true if method_name == :execute

  @tool.respond_to?(method_name, include_private) || super
end