Module: Textus::Surfaces::MCP::Catalog

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

Overview

Derives the entire MCP tool surface from the per-verb contracts (ADR 0039). ‘build_tools` builds MCP::Tool instances for the SDK; `call` is the generic dispatch: map JSON args -> (positional, keyword) per the contract, invoke the verb through the role scope, then shape the return value. No per-tool code.

Constant Summary collapse

WRITE_VERBS =
%i[
  put propose key_delete key_mv accept reject enqueue
].freeze
MAINTENANCE_VERBS =
%i[
  data_mv key_mv_prefix key_delete_prefix drain rule_lint
].freeze

Class Method Summary collapse

Class Method Details

.build_tools(mcp_server) ⇒ Object

Builds MCP::Tool instances for the SDK, bound to mcp_server.dispatch.



27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# File 'lib/textus/surfaces/mcp/catalog.rb', line 27

def build_tools(mcp_server)
  Textus::Action::VERBS
    .select { |_, klass| mcp_surfaced?(klass) }
    .map do |name, action|
      schema = action.contract.input_schema
      schema = schema.reject { |k, v| k == :required && Array(v).empty? }
      ::MCP::Tool.define(
        name: name.to_s,
        description: action.contract.summary,
        input_schema: schema,
      ) do |server_context:, **args|
        mcp_server.dispatch(name, args, server_context)
      end
    end
end

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

rubocop:disable Metrics/AbcSize



76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/textus/surfaces/mcp/catalog.rb', line 76

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

  spec = klass.contract
  inputs = Textus::Contract::Binder.inputs_from_wire(spec, args)

  invoke = lambda do |effective_inputs|
    pos, kwargs = Textus::Contract::Binder.bind(spec, effective_inputs, session: session)
    spec.args.select(&:positional).zip(pos).each { |a, v| kwargs[a.name] = v unless kwargs.key?(a.name) }
    cmd_class = Textus::Gate::VERB_COMMAND.fetch(spec.verb) do
      raise Textus::MCP::ToolError.new("unknown verb: #{spec.verb}")
    end
    merged = kwargs.merge(role: session.role)
    filled = cmd_class.members.to_h { |m| [m, merged.key?(m) ? merged[m] : nil] }
    cmd = cmd_class.new(**filled)
    store.gate.dispatch(cmd)
  end

  result = if spec.around
             Textus::Contract::Around.with(spec.around, scope: store.as(session.role), inputs: inputs, session: session, &invoke)
           else
             invoke.call(inputs)
           end
  Textus::Contract::View.render(spec, :default, result, inputs)
rescue Textus::Contract::MissingArgs => e
  raise ToolError.new("#{spec.verb}: missing #{e.missing.map { |a| a.wire.to_s }.join(", ")}")
rescue Textus::ContractDrift, CursorExpired
  raise
rescue Textus::Error => e
  raise ToolError.new("#{name}: #{e.message}")
end

.mcp_surfaced?(klass) ⇒ Boolean

Returns:

  • (Boolean)


72
73
74
# File 'lib/textus/surfaces/mcp/catalog.rb', line 72

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

.namesObject



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

def names
  Textus::Action::VERBS
    .select { |_, klass| mcp_surfaced?(klass) }
    .keys.map(&: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 verbs by verb identity (routing may be legacy UseCases or Dispatch::Actions).



54
55
56
57
58
59
# File 'lib/textus/surfaces/mcp/catalog.rb', line 54

def read_verbs
  Textus::Action::VERBS
    .reject { |verb, _klass| WRITE_VERBS.include?(verb) || MAINTENANCE_VERBS.include?(verb) }
    .select { |_verb, klass| mcp_surfaced?(klass) }
    .keys.map(&:to_s)
end

.specsObject

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



20
21
22
23
24
# File 'lib/textus/surfaces/mcp/catalog.rb', line 20

def specs
  Textus::Action::VERBS.values
                       .select { |k| mcp_surfaced?(k) }
                       .map(&:contract)
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).



66
67
68
69
70
# File 'lib/textus/surfaces/mcp/catalog.rb', line 66

def write_verbs
  Textus::Action::VERBS
    .select { |verb, klass| WRITE_VERBS.include?(verb) && mcp_surfaced?(klass) }
    .keys.map(&:to_s)
end