Class: KairosMcp::ToolRegistry

Inherits:
Object
  • Object
show all
Defined in:
lib/kairos_mcp/tool_registry.rb

Defined Under Namespace

Classes: FailClosedError, GateDeniedError

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(user_context: nil) ⇒ ToolRegistry

Returns a new instance of ToolRegistry.

Parameters:

  • user_context (Hash, nil) (defaults to: nil)

    Authenticated user info from HTTP mode



56
57
58
59
60
61
62
63
# File 'lib/kairos_mcp/tool_registry.rb', line 56

def initialize(user_context: nil)
  @safety = Safety.new
  @safety.set_user(user_context) if user_context
  @tools = {}
  @tool_sources = {}     # { tool_name(String) => :core_tool | "skillset:<name>" } — Phase 1.5
  @lifecycle_hooks = {}  # { hook_name(Symbol) => { skillset:, class_name: } }
  register_tools
end

Class Method Details

.clear_gates!Object

For testing only



49
50
51
# File 'lib/kairos_mcp/tool_registry.rb', line 49

def self.clear_gates!
  @gate_mutex.synchronize { @gates = {} }
end

.register_gate(name, &block) ⇒ Object

Register a named authorization gate. Gates are called before every tool invocation with (tool_name, arguments, safety). Raise GateDeniedError to deny access.



34
35
36
# File 'lib/kairos_mcp/tool_registry.rb', line 34

def self.register_gate(name, &block)
  @gate_mutex.synchronize { @gates[name.to_sym] = block }
end

.run_gates(tool_name, arguments, safety) ⇒ Object



42
43
44
45
46
# File 'lib/kairos_mcp/tool_registry.rb', line 42

def self.run_gates(tool_name, arguments, safety)
  @gate_mutex.synchronize { @gates.values.dup }.each do |gate|
    gate.call(tool_name, arguments, safety)
  end
end

.unregister_gate(name) ⇒ Object



38
39
40
# File 'lib/kairos_mcp/tool_registry.rb', line 38

def self.unregister_gate(name)
  @gate_mutex.synchronize { @gates.delete(name.to_sym) }
end

Instance Method Details

#call_tool(name, arguments, invocation_context: nil) ⇒ Object



363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
# File 'lib/kairos_mcp/tool_registry.rb', line 363

def call_tool(name, arguments, invocation_context: nil)
  tool = @tools[name]
  unless tool
    raise "Tool not found: #{name}"
  end

  # Defense-in-depth: enforce invocation policy at the registry boundary.
  # This duplicates the check in BaseTool#invoke_tool so that direct
  # call_tool calls with a context also respect whitelist/blacklist.
  if invocation_context && !invocation_context.allowed?(name)
    raise InvocationContext::PolicyDeniedError,
          "Tool '#{name}' blocked by invocation policy at registry boundary"
  end

  self.class.run_gates(name, arguments, @safety)
  tool.call(arguments)
rescue GateDeniedError => e
  [{ type: 'text', text: JSON.pretty_generate({ error: 'forbidden', message: e.message }) }]
rescue InvocationContext::DepthExceededError, InvocationContext::PolicyDeniedError => e
  [{ type: 'text', text: JSON.pretty_generate({ error: 'invocation_denied', message: e.message }) }]
end

#find_lifecycle_hook(hook_name) ⇒ Object

Convenience: resolve the class and instantiate. Retained for tests and callers that do not need to distinguish lookup failures from constructor failures.



180
181
182
183
184
# File 'lib/kairos_mcp/tool_registry.rb', line 180

def find_lifecycle_hook(hook_name)
  klass = lifecycle_hook_class(hook_name)
  return nil unless klass
  instantiate_lifecycle_hook(klass)
end

#instantiate_lifecycle_hook(klass) ⇒ Object



143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/kairos_mcp/tool_registry.rb', line 143

def instantiate_lifecycle_hook(klass)
  instance = klass.new
  # `KairosMcp::LifecycleHook === instance` uses Module#=== — does
  # not call any method on `instance`, so it works on BasicObject.
  unless KairosMcp::LifecycleHook === instance
    class_label =
      (klass.name && !klass.name.empty?) ? klass.name : klass.inspect
    raise LifecycleHook::InstanceViolation,
          "#{class_label}.new returned #{safe_inspect(instance)} " \
          "(#{safe_class_name(instance)}) which does not include " \
          'KairosMcp::LifecycleHook'
  end
  instance
end

#lifecycle_hook_class(hook_name) ⇒ Object

Resolve a registered hook to its Class (without instantiating). Returns nil if no SkillSet declared the hook. Raises LifecycleHook::UnknownClass if the registered class name cannot be constantized or does not include LifecycleHook.

R8→R9 (3-voice: Codex P1 / 4.6 P2 / 4.7 P2): split class-resolution from instantiation so bin/ can .new under a precise rescue. The broad rescue StandardError in the entrypoint otherwise mislabels any registry-logic bug as an instantiation failure.



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/kairos_mcp/tool_registry.rb', line 103

def lifecycle_hook_class(hook_name)
  entry = @lifecycle_hooks[hook_name.to_sym]
  return nil unless entry
  begin
    klass = Object.const_get(entry[:class_name])
  rescue NameError => e
    raise LifecycleHook::UnknownClass,
          "lifecycle hook class '#{entry[:class_name]}' is not defined " \
          "(declared by '#{entry[:skillset]}'): #{e.message}"
  end
  unless klass.is_a?(Class) && klass.include?(KairosMcp::LifecycleHook)
    raise LifecycleHook::UnknownClass,
          "class '#{entry[:class_name]}' does not include KairosMcp::LifecycleHook"
  end
  klass
end

#lifecycle_hook_namesObject



186
187
188
# File 'lib/kairos_mcp/tool_registry.rb', line 186

def lifecycle_hook_names
  @lifecycle_hooks.keys
end

#list_toolsObject



341
342
343
# File 'lib/kairos_mcp/tool_registry.rb', line 341

def list_tools
  @tools.values.map(&:to_schema)
end

#register_dynamic_tool(tool_instance) ⇒ Object

Register a pre-built tool instance (e.g., proxy tools from mcp_client). Cannot overwrite local (non-proxy) tools to prevent accidental replacement.



347
348
349
350
351
352
353
354
355
# File 'lib/kairos_mcp/tool_registry.rb', line 347

def register_dynamic_tool(tool_instance)
  name = tool_instance.name
  existing = @tools[name]
  if existing && !existing.respond_to?(:remote_name)
    raise "Cannot override local tool '#{name}' with dynamic registration"
  end
  @tools[name] = tool_instance
  @tool_sources[name] = :dynamic_proxy
end

#register_lifecycle_hook(hook_name, class_name, skillset_name:) ⇒ Object

24/7 v0.4 §2.3 — LifecycleHook registry.

Register a hook declaration from a SkillSet. Conflicts (same hook name claimed by two SkillSets) raise LifecycleHook::Conflict — the Bootstrap layer refuses to silently pick a winner.



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/kairos_mcp/tool_registry.rb', line 70

def register_lifecycle_hook(hook_name, class_name, skillset_name:)
  key = hook_name.to_sym
  # R1 P1 (2-voice security): validate class name + enforce namespace
  # allowlist before trusting any skillset-sourced class identifier.
  validated = LifecycleHook.validate_class_name!(class_name)

  existing = @lifecycle_hooks[key]
  if existing && existing[:skillset] != skillset_name
    raise LifecycleHook::Conflict,
          "LifecycleHook '#{hook_name}' claimed by both " \
          "'#{existing[:skillset]}' and '#{skillset_name}'"
  end
  # R1 P2 (3-voice): same-skillset re-registration must not silently
  # overwrite with a DIFFERENT class. Same-class re-registration is a
  # harmless idempotent load (tests, reload).
  if existing && existing[:class_name] != validated
    raise LifecycleHook::Conflict,
          "LifecycleHook '#{hook_name}' re-registered by " \
          "'#{skillset_name}' with different class " \
          "('#{existing[:class_name]}' → '#{validated}')"
  end
  @lifecycle_hooks[key] = { skillset: skillset_name, class_name: validated }
end

#register_skill_toolsObject

Register tools defined in kairos.rb via tool block



326
327
328
329
330
331
332
333
334
335
# File 'lib/kairos_mcp/tool_registry.rb', line 326

def register_skill_tools
  require_relative 'skill_tool_adapter'
  require_relative 'kairos'

  Kairos.skills.each do |skill|
    next unless skill.has_tool?  # Only skills with tool block and executor
    adapter = SkillToolAdapter.new(skill, @safety, registry: self)
    register(adapter)
  end
end

#register_skillset_toolsObject

Register tools from enabled SkillSets



277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
# File 'lib/kairos_mcp/tool_registry.rb', line 277

def register_skillset_tools
  require_relative 'skillset_manager'

  manager = SkillSetManager.new
  manager.enabled_skillsets.each do |skillset|
    # Per-skillset isolation: one failing SkillSet must not abort the
    # loop and silently skip later ones (a fail-closed regime loaded
    # later would otherwise never register its gate).
    begin
      skillset.load!
      # Fail-closed activation hook (before tool registration): a
      # regime that ships enabled must register its enforcement at load
      # time, not as a side effect of some tool being instantiated.
      # ANY failure to resolve or run the hook is fail-closed: a
      # missing/typo'd hook constant must not degrade to a warn and
      # leave an enabled regime unguarded.
      if (hook = skillset.activation_hook)
        begin
          LifecycleHook.validate_class_name!(hook)
          Object.const_get(hook).activate_on_load!(registry_class: self.class)
        rescue FailClosedError
          raise
        rescue StandardError => e
          raise FailClosedError,
                "activation_hook '#{hook}' (skillset '#{skillset.name}') failed to activate: " \
                "#{e.class}: #{e.message}"
        end
      end
      skillset.tool_class_names.each do |cls|
        # Phase 1.5: thread source attribution for capability_status manifest
        register_if_defined(cls, source: "skillset:#{skillset.name}")
      end
      # 24/7 v0.4 §2.3 — register lifecycle hooks declared by this SkillSet.
      skillset.lifecycle_hooks.each do |hook_name, class_name|
        register_lifecycle_hook(hook_name, class_name, skillset_name: skillset.name)
      end
    rescue LifecycleHook::Conflict, FailClosedError
      raise  # never swallow — integrity/fail-closed regimes depend on detection
    rescue StandardError => e
      warn "[ToolRegistry] Failed to load SkillSet '#{skillset.name}': #{e.message}"
    end
  end
rescue LifecycleHook::Conflict, FailClosedError
  raise  # never swallow — Bootstrap integrity depends on detection
rescue StandardError => e
  warn "[ToolRegistry] Failed to load SkillSet tools: #{e.message}"
end

#register_toolsObject



190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
# File 'lib/kairos_mcp/tool_registry.rb', line 190

def register_tools
  # Load all tool files
  Dir[File.join(__dir__, 'tools', '*.rb')].each do |file|
    require file
  end

  # Register tools
  register_if_defined('KairosMcp::Tools::HelloWorld')

  # Phase 1.5 — Capability Boundary self-articulation
  register_if_defined('KairosMcp::Tools::CapabilityStatus')

  # L0-A: skills/kairos.md (read-only)
  register_if_defined('KairosMcp::Tools::SkillsList')
  register_if_defined('KairosMcp::Tools::SkillsGet')
  
  # L0-B: skills/kairos.rb (self-modifying with full blockchain record)
  register_if_defined('KairosMcp::Tools::SkillsDslList')
  register_if_defined('KairosMcp::Tools::SkillsDslGet')
  register_if_defined('KairosMcp::Tools::SkillsEvolve')
  register_if_defined('KairosMcp::Tools::SkillsRollback')
  
  # Cross-layer promotion with optional Persona Assembly
  register_if_defined('KairosMcp::Tools::SkillsPromote')
  
  # Audit tools (health checks, archive management, recommendations)
  register_if_defined('KairosMcp::Tools::SkillsAudit')

  # L0: Instructions management (system prompt control with full blockchain record)
  register_if_defined('KairosMcp::Tools::InstructionsUpdate')
  
  # Resource tools (unified access to L0/L1/L2 resources)
  register_if_defined('KairosMcp::Tools::ResourceList')
  register_if_defined('KairosMcp::Tools::ResourceRead')
  register_if_defined('KairosMcp::Tools::ResourceRender')

  # L1: knowledge/ (Anthropic skills format with hash-only blockchain record)
  register_if_defined('KairosMcp::Tools::KnowledgeList')
  register_if_defined('KairosMcp::Tools::KnowledgeGet')
  register_if_defined('KairosMcp::Tools::KnowledgeUpdate')
  
  # L2: context/ (Anthropic skills format without blockchain record)
  register_if_defined('KairosMcp::Tools::ContextSave')
  register_if_defined('KairosMcp::Tools::ContextCreateSubdir')
  
  # Chain tools
  register_if_defined('KairosMcp::Tools::ChainStatus')
  register_if_defined('KairosMcp::Tools::ChainRecord')
  register_if_defined('KairosMcp::Tools::ChainVerify')
  register_if_defined('KairosMcp::Tools::ChainHistory')
  register_if_defined('KairosMcp::Tools::ChainExport')
  register_if_defined('KairosMcp::Tools::ChainImport')

  # Formalization tools (DSL/AST partial formalization records)
  register_if_defined('KairosMcp::Tools::FormalizationRecord')
  register_if_defined('KairosMcp::Tools::FormalizationHistory')

  # Definition analysis tools (Phase 2: verification, decompilation, drift detection)
  register_if_defined('KairosMcp::Tools::DefinitionVerify')
  register_if_defined('KairosMcp::Tools::DefinitionDecompile')
  register_if_defined('KairosMcp::Tools::DefinitionDrift')

  # State commit tools (auditability)
  register_if_defined('KairosMcp::Tools::StateCommit')
  register_if_defined('KairosMcp::Tools::StateStatus')
  register_if_defined('KairosMcp::Tools::StateHistory')

  # Guide tools (discovery, help, metadata management)
  register_if_defined('KairosMcp::Tools::ToolGuide')

  # Token management (HTTP authentication)
  register_if_defined('KairosMcp::Tools::TokenManage')

  # System management tools (upgrade, migration)
  register_if_defined('KairosMcp::Tools::SystemUpgrade')

  # SkillSet-based tools (opt-in plugins from .kairos/skillsets/)
  register_skillset_tools

  # Skill-based tools (from kairos.rb with tool block)
  register_skill_tools if skill_tools_enabled?

  # Restore dynamic proxy tools from active mcp_client connections (Phase 4)
  restore_dynamic_tools
end

#set_workspace(roots) ⇒ Object



337
338
339
# File 'lib/kairos_mcp/tool_registry.rb', line 337

def set_workspace(roots)
  @safety.set_workspace(roots)
end

#unregister_tool(name) ⇒ Object

Remove a dynamically registered tool (e.g., on mcp_disconnect).



358
359
360
361
# File 'lib/kairos_mcp/tool_registry.rb', line 358

def unregister_tool(name)
  @tool_sources.delete(name)
  @tools.delete(name)
end