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
- .attach(agent_class, client) ⇒ Object
- .attach_pending(klass) ⇒ Object
- .attached(klass) ⇒ Object
- .client_factory ⇒ Object
- .client_factory=(factory) ⇒ Object
- .defer(agent_class, name, options) ⇒ Object
-
.description(tool) ⇒ Object
The signature is
**arguments, so what those arguments are goes here. - .ensure(agent_class) ⇒ Object
-
.method_name(tool) ⇒ Object
Tool names may hold characters a Ruby method name cannot.
- .mutex_for(klass) ⇒ Object
- .pending?(agent_class) ⇒ Boolean
-
.result(value) ⇒ Object
ponytail: text only — an image or audio result is dropped.
- .servers(klass) ⇒ Object
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, | 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, **)) 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, **) { require "ruby_llm/mcp" RubyLLM::MCP.add_client(name: name.to_s, **) } 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, ) servers(agent_class)[name.to_sym] = 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
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.
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 |