Class: McpAuthorization::Tool
- Inherits:
-
MCP::Tool
- Object
- MCP::Tool
- McpAuthorization::Tool
- 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"
:view_orders # RBAC permission (legacy, still supported)
gate :feature, :order_tracking # generic predicate gate (any predicate name)
"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
-
._contract_handler ⇒ Object
readonly
: untyped.
-
._gates ⇒ Object
readonly
: Array[Hash[Symbol, untyped]]?.
-
._permission ⇒ Object
readonly
: Symbol?.
-
._tags ⇒ Object
readonly
: Array?.
Class Method Summary collapse
-
.authorization(permission) ⇒ Object
Declare the RBAC permission flag required to see this tool.
-
.call(server_context: nil, **params) ⇒ Object
Execute the tool by delegating to the handler.
-
.closed_world! ⇒ Object
: () -> void.
-
.destructive! ⇒ Object
: () -> void.
-
.dynamic_contract(handler_class) ⇒ Object
Point this tool at its handler class.
-
.dynamic_description(server_context:) ⇒ Object
Build the tool description for this user.
-
.dynamic_input_schema(server_context:) ⇒ Object
Compile the input JSON Schema for this user.
-
.dynamic_output_schema(server_context:) ⇒ Object
Compile the output JSON Schema for this user.
-
.gate(predicate_name, value) ⇒ Object
Declare a generic predicate gate that must pass for this tool to be visible.
-
.idempotent! ⇒ Object
: () -> void.
-
.inherited(subclass) ⇒ Object
: (Class) -> void.
-
.materialize_for(server_context) ⇒ Object
Create an anonymous MCP::Tool subclass with this user’s schemas baked in.
-
.not_destructive! ⇒ Object
: () -> void.
-
.open_world! ⇒ Object
: () -> void.
-
.permitted?(server_context) ⇒ Boolean
Check whether the current user is allowed to see this tool.
-
.read_only! ⇒ Object
MCP annotation hint shorthands : () -> void.
-
.symbolize_keys(hash) ⇒ Object
Normalize hash keys to symbols so projection output can be splatted into a handler’s kwarg-only
#callsignature. -
.tags(*list) ⇒ Object
Declare which MCP domains this tool belongs to.
-
.to_mcp_definition(server_context:) ⇒ Object
Build the full MCP tool definition hash for
tools/list.
Class Attribute Details
._contract_handler ⇒ Object (readonly)
: untyped
41 42 43 |
# File 'lib/mcp_authorization/tool.rb', line 41 def _contract_handler @_contract_handler end |
._gates ⇒ Object (readonly)
: Array[Hash[Symbol, untyped]]?
38 39 40 |
# File 'lib/mcp_authorization/tool.rb', line 38 def _gates @_gates end |
._permission ⇒ Object (readonly)
: Symbol?
32 33 34 |
# File 'lib/mcp_authorization/tool.rb', line 32 def @_permission end |
._tags ⇒ Object (readonly)
: Array?
35 36 37 |
# File 'lib/mcp_authorization/tool.rb', line 35 def @_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 () @_permission = gate :requires, 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
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
: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
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
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 (*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 |