Class: McpAuthorization::Tool

Inherits:
MCP::Tool
  • Object
show all
Defined in:
lib/mcp_authorization/tool.rb

Overview

Base class for MCP tools with schema-shaping authorization.

Subclass this instead of MCP::Tool directly. Each subclass is a thin declarative wrapper — the actual business logic lives in a *handler class* (a plain Ruby class that includes DSL) pointed to by dynamic_contract.

Defining a tool

class Tools::ListOrders < McpAuthorization::Tool
  tool_name "list_orders"
  authorization :view_orders     # RBAC permission (legacy, still supported)
  gate :feature, :order_tracking # generic predicate gate (any predicate name)
  tags "operator", "fulfillment"
  read_only!

  dynamic_contract Handlers::ListOrders
end

Both authorization and any number of gate declarations contribute to visibility — the tool is shown only when every check passes. See permitted? for the resolution order.

Defined Under Namespace

Classes: NotAuthorizedError

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

._contract_handlerObject (readonly)

: untyped



41
42
43
# File 'lib/mcp_authorization/tool.rb', line 41

def _contract_handler
  @_contract_handler
end

._gatesObject (readonly)

: Array[Hash[Symbol, untyped]]?



38
39
40
# File 'lib/mcp_authorization/tool.rb', line 38

def _gates
  @_gates
end

._permissionObject (readonly)

: Symbol?



32
33
34
# File 'lib/mcp_authorization/tool.rb', line 32

def _permission
  @_permission
end

._tagsObject (readonly)

: Array?



35
36
37
# File 'lib/mcp_authorization/tool.rb', line 35

def _tags
  @_tags
end

Class Method Details

.authorization(permission) ⇒ Object

Declare the RBAC permission flag required to see this tool.

Convenience alias for gate :requires, permission. The generic gate pipeline handles dispatch — calling server_context.requires?(permission) when defined, otherwise falling back to current_user.can?(permission). This mirrors the field-level migration done in 0.3.0 (#12): @requires also went through the generic predicate pipeline rather than carrying its own special-cased branch.

_permission remains exposed for introspection — the value is written there as before — but the actual gating goes through the gate list at permitted? time, just like every other check. : (Symbol) -> void



63
64
65
66
# File 'lib/mcp_authorization/tool.rb', line 63

def authorization(permission)
  @_permission = permission
  gate :requires, permission
end

.call(server_context: nil, **params) ⇒ Object

Execute the tool by delegating to the handler.

Inputs are filtered against the user’s compiled input schema before being passed to the handler, and outputs are filtered against the user’s compiled output schema before being returned. Fields and variants gated by @requires that the user lacks permission for never reach the handler (in) or cross the wire (out). : (?server_context: untyped?, **untyped) -> untyped

Raises:



182
183
184
185
186
187
188
189
190
191
# File 'lib/mcp_authorization/tool.rb', line 182

def call(server_context: nil, **params)
  raise NotAuthorizedError unless server_context && permitted?(server_context)
  filtered = McpAuthorization::RbsSchemaCompiler.filter_input(
    _contract_handler, params, server_context: server_context
  )
  result = handler_instance(server_context).call(**symbolize_keys(filtered))
  McpAuthorization::RbsSchemaCompiler.filter_output(
    _contract_handler, result, server_context: server_context
  )
end

.closed_world!Object

: () -> void



110
# File 'lib/mcp_authorization/tool.rb', line 110

def closed_world!;    merge_annotations(open_world_hint: false) end

.destructive!Object

: () -> void



102
# File 'lib/mcp_authorization/tool.rb', line 102

def destructive!;     merge_annotations(destructive_hint: true) end

.dynamic_contract(handler_class) ⇒ Object

Point this tool at its handler class. : (untyped) -> void



114
115
116
117
# File 'lib/mcp_authorization/tool.rb', line 114

def dynamic_contract(handler_class)
  @_contract_handler = handler_class
  @_contract_validated = false
end

.dynamic_description(server_context:) ⇒ Object

Build the tool description for this user. : (server_context: untyped) -> String



121
122
123
# File 'lib/mcp_authorization/tool.rb', line 121

def dynamic_description(server_context:)
  handler_instance(server_context).description
end

.dynamic_input_schema(server_context:) ⇒ Object

Compile the input JSON Schema for this user. : (server_context: untyped) -> Hash[Symbol, untyped]



127
128
129
130
131
132
# File 'lib/mcp_authorization/tool.rb', line 127

def dynamic_input_schema(server_context:)
  McpAuthorization::RbsSchemaCompiler.compile_input(
    _contract_handler,
    server_context: server_context
  )
end

.dynamic_output_schema(server_context:) ⇒ Object

Compile the output JSON Schema for this user. : (server_context: untyped) -> Hash[Symbol, untyped]?



136
137
138
139
140
141
# File 'lib/mcp_authorization/tool.rb', line 136

def dynamic_output_schema(server_context:)
  McpAuthorization::RbsSchemaCompiler.compile_output(
    _contract_handler,
    server_context: server_context
  )
end

.gate(predicate_name, value) ⇒ Object

Declare a generic predicate gate that must pass for this tool to be visible. The gate calls server_context.{predicate}?(value) at request time. If the predicate returns false, the tool is hidden from tools/list and rejected from tools/call.

Mirrors the field-level @predicate(:value) system: any predicate name works, as long as the server_context implements {predicate}?(value).

class BulkSendSmsTool < McpAuthorization::Tool
  authorization :communications  # RBAC (existing)
  gate :feature, :sms            # hide tool unless account has SMS configured
  gate :requires, :super_user    # extra RBAC check beyond authorization
end

Multiple gate calls AND together — every gate must pass.

: (Symbol, untyped) -> void

Parameters:

  • predicate_name (Symbol)

    Predicate name; resolved to {predicate_name}? on the context.

  • value (Symbol, String)

    Argument passed to the predicate method.



94
95
96
# File 'lib/mcp_authorization/tool.rb', line 94

def gate(predicate_name, value)
  (@_gates ||= []) << { name: predicate_name.to_sym, value: value }
end

.idempotent!Object

: () -> void



106
# File 'lib/mcp_authorization/tool.rb', line 106

def idempotent!;      merge_annotations(idempotent_hint: true) end

.inherited(subclass) ⇒ Object

: (Class) -> void



44
45
46
47
# File 'lib/mcp_authorization/tool.rb', line 44

def inherited(subclass)
  super
  McpAuthorization::ToolRegistry.register(subclass)
end

.materialize_for(server_context) ⇒ Object

Create an anonymous MCP::Tool subclass with this user’s schemas baked in.

The materialized call enforces the compiled schema at runtime: input params are stripped of unknown or permission-gated fields before reaching the handler, and the handler’s return value is projected onto the user’s output schema before being serialized. : (untyped) -> Class?



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
# File 'lib/mcp_authorization/tool.rb', line 200

def materialize_for(server_context)
  defn = to_mcp_definition(server_context: server_context)
  return nil unless defn

  handler = _contract_handler
  ctx = server_context
  symbolize = method(:symbolize_keys)

  Class.new(MCP::Tool) do
    tool_name defn[:name]
    description defn[:description]
    input_schema defn[:inputSchema]
    output_schema defn[:outputSchema] if defn[:outputSchema]
    annotations(**defn[:annotations]) if defn[:annotations]&.any?

    define_singleton_method(:call) do |server_context: nil, **params|
      effective_ctx = server_context || ctx
      filtered_params = McpAuthorization::RbsSchemaCompiler.filter_input(
        handler, params, server_context: effective_ctx
      )
      raw = handler.new(server_context: effective_ctx).call(**symbolize.call(filtered_params))
      result = McpAuthorization::RbsSchemaCompiler.filter_output(
        handler, raw, server_context: effective_ctx
      )
      response_args = [{ type: "text", text: result.to_json }]
      if defn[:outputSchema]
        MCP::Tool::Response.new(response_args, structured_content: result)
      else
        MCP::Tool::Response.new(response_args)
      end
    end
  end
end

.not_destructive!Object

: () -> void



104
# File 'lib/mcp_authorization/tool.rb', line 104

def not_destructive!; merge_annotations(destructive_hint: false) end

.open_world!Object

: () -> void



108
# File 'lib/mcp_authorization/tool.rb', line 108

def open_world!;      merge_annotations(open_world_hint: true) end

.permitted?(server_context) ⇒ Boolean

Check whether the current user is allowed to see this tool.

Evaluates every declared gate against the server context. A tool is permitted only when every gate passes. With no gates declared, the tool is unconditionally visible.

authorization :perm contributes a gate :requires, :perm internally, so it goes through the same pipeline as every other predicate. There is one code path for gating, not two. : (untyped) -> bool

Returns:

  • (Boolean)


153
154
155
# File 'lib/mcp_authorization/tool.rb', line 153

def permitted?(server_context)
  gates_pass?(server_context)
end

.read_only!Object

MCP annotation hint shorthands : () -> void



100
# File 'lib/mcp_authorization/tool.rb', line 100

def read_only!;       merge_annotations(read_only_hint: true) end

.symbolize_keys(hash) ⇒ Object

Normalize hash keys to symbols so projection output can be splatted into a handler’s kwarg-only #call signature. : (Hash[untyped, untyped]) -> Hash[Symbol, untyped]



237
238
239
240
# File 'lib/mcp_authorization/tool.rb', line 237

def symbolize_keys(hash)
  return {} unless hash.is_a?(Hash)
  hash.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
end

.tags(*list) ⇒ Object

Declare which MCP domains this tool belongs to. : (*String | Array) -> void



70
71
72
# File 'lib/mcp_authorization/tool.rb', line 70

def tags(*list)
  @_tags = list.flatten
end

.to_mcp_definition(server_context:) ⇒ Object

Build the full MCP tool definition hash for tools/list. Returns nil if the user is not permitted. : (server_context: untyped) -> Hash[Symbol, untyped]?



160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/mcp_authorization/tool.rb', line 160

def to_mcp_definition(server_context:)
  return nil unless permitted?(server_context)
  validate_contract!(_contract_handler) unless @_contract_validated
  @_contract_validated = true

  {
    name: tool_name,
    description: dynamic_description(server_context: server_context),
    inputSchema: dynamic_input_schema(server_context: server_context),
    outputSchema: dynamic_output_schema(server_context: server_context),
    annotations: @_annotations_hash || {}
  }
end