Module: Textus::MCP::Catalog

Defined in:
lib/textus/mcp/catalog.rb

Overview

Derives the entire MCP tool surface from the per-verb contracts (ADR 0039). ‘tool_schemas` feeds tools/list; `call` is the generic tools/call dispatch: map JSON args -> (positional, keyword) per the contract, invoke the verb through the role scope, then shape the return value with the contract’s response block. No per-tool code.

Class Method Summary collapse

Class Method Details

.call(name, session:, store:, args:) ⇒ Object



53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/textus/mcp/catalog.rb', line 53

def call(name, session:, store:, args:)
  klass = Textus::Dispatcher::VERBS[name.to_sym]
  raise ToolError.new("unknown tool: #{name}") unless klass && mcp_surfaced?(klass)

  spec = klass.contract
  pos, kw = map_args(spec, args || {}, session)
  result = store.as(session.role).public_send(spec.verb, *pos, **kw)
  spec.response.call(result)
rescue ContractDrift, CursorExpired
  raise
rescue Textus::Error => e
  raise ToolError.new("#{name}: #{e.message}")
end

.map_args(spec, raw, session = nil) ⇒ Object

Splits the raw JSON arg hash into the positional list and keyword hash the use-case expects, validating required presence first. Session-default args (session_default: :method_name) are injected from the session when absent from the wire; they are never treated as missing. Positional args are emitted in contract declaration order; use-case signatures must match.

Raises:



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

def map_args(spec, raw, session = nil)
  missing = spec.required_args.map { |a| a.wire.to_s } - raw.keys
  raise ToolError.new("#{spec.verb}: missing #{missing.join(", ")}") unless missing.empty?

  positional = []
  keyword = {}
  spec.args.each do |a|
    if raw.key?(a.wire.to_s)
      value = raw[a.wire.to_s]
    elsif a.session_default && session
      value = session.public_send(a.session_default)
    else
      next
    end

    if a.positional
      positional << value
    else
      keyword[a.name] = value
    end
  end
  [positional, keyword]
end

.mcp_surfaced?(klass) ⇒ Boolean

Returns:

  • (Boolean)


49
50
51
# File 'lib/textus/mcp/catalog.rb', line 49

def mcp_surfaced?(klass)
  klass.respond_to?(:contract?) && klass.contract? && klass.contract.mcp?
end

.namesObject



24
25
26
# File 'lib/textus/mcp/catalog.rb', line 24

def names
  specs.map { |s| s.verb.to_s }
end

.read_verbsObject

MCP-surfaced read verbs, by Dispatcher class namespace — the agent’s real read/discovery surface. ‘boot.agent_quickstart.read_verbs` derives from this so it can never advertise a verb the agent cannot call, nor omit one it can (ADR 0056). Excludes Write/Maintenance.



32
33
34
35
36
# File 'lib/textus/mcp/catalog.rb', line 32

def read_verbs
  Textus::Dispatcher::VERBS
    .select { |_verb, klass| mcp_surfaced?(klass) && klass.name.start_with?("Textus::Read::") }
    .keys.map(&:to_s)
end

.specsObject

Contracts of every MCP-surfaced verb, in Dispatcher order.



12
13
14
15
16
# File 'lib/textus/mcp/catalog.rb', line 12

def specs
  Textus::Dispatcher::VERBS.values
                           .select { |k| mcp_surfaced?(k) }
                           .map(&:contract)
end

.tool_schemasObject



18
19
20
21
22
# File 'lib/textus/mcp/catalog.rb', line 18

def tool_schemas
  specs.map do |s|
    { name: s.verb.to_s, description: s.summary, inputSchema: s.input_schema }
  end.freeze
end

.write_verbsObject

MCP-surfaced write verbs, by Dispatcher class namespace — the mirror of read_verbs for the write side. ‘boot.agent_quickstart.write_verbs` derives from this so it advertises bare verb names the agent can call (no `–as`/ `–stdin` CLI framing), finishing the de-CLI-ing of the agent surface (ADR 0056, ADR 0057).



43
44
45
46
47
# File 'lib/textus/mcp/catalog.rb', line 43

def write_verbs
  Textus::Dispatcher::VERBS
    .select { |_verb, klass| mcp_surfaced?(klass) && klass.name.start_with?("Textus::Write::") }
    .keys.map(&:to_s)
end