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

._categoryObject (readonly)

: Symbol?



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

def _category
  @_category
end

._category_summaryObject (readonly)

: String?



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

def _category_summary
  @_category_summary
end

._contract_handlerObject (readonly)

: untyped



47
48
49
# File 'lib/mcp_authorization/tool.rb', line 47

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



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

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:



204
205
206
207
208
209
210
211
212
213
# File 'lib/mcp_authorization/tool.rb', line 204

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

.category(name, summary: nil) ⇒ Object

Declare the group this tool belongs to when its domain is faceted (see Configuration#facet_domain). Ignored in flat domains.

class ListOrdersTool < McpAuthorization::Tool
tags "admin"
category :orders
end

The optional summary: is a convenience for single-tool groups; the central config.categories registry wins on conflict. : (Symbol | String, ?summary: String?) -> void



91
92
93
94
# File 'lib/mcp_authorization/tool.rb', line 91

def category(name, summary: nil)
  @_category = name.to_sym
  @_category_summary = summary
end

.closed_world!Object

: () -> void



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

def closed_world!;    merge_annotations(open_world_hint: false) end

.destructive!Object

: () -> void



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

def destructive!;     merge_annotations(destructive_hint: true) end

.dynamic_contract(handler_class) ⇒ Object

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



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

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



143
144
145
# File 'lib/mcp_authorization/tool.rb', line 143

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]



149
150
151
152
153
154
# File 'lib/mcp_authorization/tool.rb', line 149

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]?



158
159
160
161
162
163
# File 'lib/mcp_authorization/tool.rb', line 158

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.



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

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

.idempotent!Object

: () -> void



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

def idempotent!;      merge_annotations(idempotent_hint: true) end

.inherited(subclass) ⇒ Object

: (Class) -> void



50
51
52
53
# File 'lib/mcp_authorization/tool.rb', line 50

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?



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

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



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

def not_destructive!; merge_annotations(destructive_hint: false) end

.open_world!Object

: () -> void



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

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)


175
176
177
# File 'lib/mcp_authorization/tool.rb', line 175

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

.read_only!Object

MCP annotation hint shorthands : () -> void



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

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]



259
260
261
262
# File 'lib/mcp_authorization/tool.rb', line 259

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



76
77
78
# File 'lib/mcp_authorization/tool.rb', line 76

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]?



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

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