Module: Omakase::MCP

Defined in:
lib/omakase/mcp.rb

Overview

An MCP server's tools, as methods on the agent — so generated code calls a remote tool the same way it calls anything else the agent exposes.

Constant Summary collapse

LOCKS =
Mutex.new
Clash =

A tool name landing on a method is your bug, not a sidecar being down — and both arrive as an Error out of the same call.

Class.new(Error)

Class Method Summary collapse

Class Method Details

.attach(agent_class, client) ⇒ Object



70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/omakase/mcp.rb', line 70

def attach(agent_class, client)
  defined = []
  client.tools.each do |tool|
    name = method_name(tool)
    # A remote tool list must not quietly shadow a capability the agent already has.
    raise Clash, "#{agent_class} already has ##{name}" if Capabilities.names(agent_class).include?(name)

    agent_class.describe(description(tool))
    # nil is how a model leaves an argument out; MCP servers reject it.
    agent_class.define_method(name) { |**arguments| MCP.result(tool.execute(**arguments.compact)) }
    defined << name
  end
  client
rescue
  # Else the next ensure dies on "already has #name".
  defined.each do |name|
    agent_class.send(:remove_method, name)
    agent_class.descriptions.delete(name)
  end
  agent_class.instance_variable_set(:@pending_description, nil)
  client.close if client.respond_to?(:close)
  raise
end

.attach_pending(klass) ⇒ Object



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/omakase/mcp.rb', line 51

def attach_pending(klass)
  servers(klass).each do |name, options|
    next if attached(klass).include?(name)

    # A down sidecar stays unattached — connecting and listing its tools are
    # the same trip. The generate still runs. Next ensure retries. A name
    # clash is not the sidecar being down, it is your bug, so it raises.
    begin
      attach(klass, client_factory.call(name, **options))
    rescue Clash
      raise
    rescue => e
      Omakase.emit(:mcp, agent: klass, name:, error: e)
      next
    end
    attached(klass) << name
  end
end

.attached(klass) ⇒ Object



128
129
130
# File 'lib/omakase/mcp.rb', line 128

def attached(klass)
  klass.instance_variable_get(:@mcp_attached) || klass.instance_variable_set(:@mcp_attached, [])
end

.client_factory ⇒ Object



21
22
23
24
25
26
# File 'lib/omakase/mcp.rb', line 21

def client_factory
  @client_factory || ->(name, **options) {
    require "ruby_llm/mcp"
    RubyLLM::MCP.add_client(name: name.to_s, **options)
  }
end

.client_factory=(factory) ⇒ Object



13
14
15
16
17
18
19
# File 'lib/omakase/mcp.rb', line 13

def client_factory=(factory)
  unless factory.nil? || factory.respond_to?(:call)
    raise Error, "client_factory must answer call, got #{factory.class}"
  end

  @client_factory = factory
end

.defer(agent_class, name, options) ⇒ Object



31
32
33
# File 'lib/omakase/mcp.rb', line 31

def defer(agent_class, name, options)
  servers(agent_class)[name.to_sym] = options
end

.description(tool) ⇒ Object

The signature is **arguments, so what those arguments are goes here.



105
106
107
108
109
110
111
112
113
# File 'lib/omakase/mcp.rb', line 105

def description(tool)
  schema = tool.params_schema || {}
  required = schema["required"] || []
  arguments = (schema["properties"] || {}).map do |name, property|
    "#{name}: #{property["type"]}#{" (required)" if required.include?(name)}"
  end
  text = tool.description.to_s.gsub(/\s+/, " ").strip
  [text, ("Arguments — #{arguments.join(", ")}" if arguments.any?)].compact.join(" ")
end

.ensure(agent_class) ⇒ Object



35
36
37
38
39
40
41
42
43
# File 'lib/omakase/mcp.rb', line 35

def ensure(agent_class)
  return unless pending?(agent_class)

  agent_class.ancestors.take_while { |mod| mod != Agent }.reverse_each do |klass|
    next unless klass.is_a?(Class)

    mutex_for(klass).synchronize { attach_pending(klass) }
  end
end

.method_name(tool) ⇒ Object

Tool names may hold characters a Ruby method name cannot.



95
# File 'lib/omakase/mcp.rb', line 95

def method_name(tool) = tool.name.tr("-", "_").to_sym

.mutex_for(klass) ⇒ Object



115
116
117
118
119
120
121
122
# File 'lib/omakase/mcp.rb', line 115

def mutex_for(klass)
  mutex = klass.instance_variable_get(:@mcp_mutex)
  return mutex if mutex

  LOCKS.synchronize do
    klass.instance_variable_get(:@mcp_mutex) || klass.instance_variable_set(:@mcp_mutex, Mutex.new)
  end
end

.pending?(agent_class) ⇒ Boolean

Returns:

  • (Boolean)


45
46
47
48
49
# File 'lib/omakase/mcp.rb', line 45

def pending?(agent_class)
  agent_class.ancestors.take_while { |mod| mod != Agent }.any? do |mod|
    mod.is_a?(Class) && (servers(mod).keys - attached(mod)).any?
  end
end

.result(value) ⇒ Object

ponytail: text only — an image or audio result is dropped.

Raises:



98
99
100
101
102
# File 'lib/omakase/mcp.rb', line 98

def result(value)
  raise Error, value[:error] if value.is_a?(Hash) && value[:error]

  value.to_s
end

.servers(klass) ⇒ Object



124
125
126
# File 'lib/omakase/mcp.rb', line 124

def servers(klass)
  klass.instance_variable_get(:@mcp_servers) || klass.instance_variable_set(:@mcp_servers, {})
end