Class: McpAuthorization::RbsSchemaCompiler
- Inherits:
-
Object
- Object
- McpAuthorization::RbsSchemaCompiler
- Defined in:
- lib/mcp_authorization/rbs_schema_compiler.rb
Overview
Compiles RBS-style type annotations in Ruby source files into JSON Schema, with per-request filtering based on @requires permission tags.
This is the heart of the schema-shaping authorization approach. Rather than defining JSON Schema separately, handler authors annotate their Ruby source files with RBS-style comments:
# @rbs type output = success | admin_detail @requires(:admin)
#: (name: String, ?force: bool @requires(:admin)) -> Hash[Symbol, untyped]
def call(name:, force: false)
The compiler parses these annotations once and caches the result. On each request, only the @requires filtering runs — checking which fields/variants the current user can see and building a tailored schema.
Two-phase design
*Parse phase* (cached, runs once per handler class):
-
Locate the handler’s source file via Method#source_location
-
Load shared types from # @rbs import statements
-
Parse local # @rbs type definitions into a type map
-
Parse the
#:annotation above def call into parameter descriptors
*Compile phase* (per-request):
-
Filter parameters/variants by @requires tags against
current_user.can? -
Apply constraint tags (+@min+, @format, etc.) to JSON Schema keywords
-
Inject $ref/$defs when named types appear more than once (saves space)
Supported annotation tags
See extract_tags for the full list. Key tags:
-
@requires(:flag) — field is omitted from schema if user lacks this permission
-
@min(n), @max(n) — type-aware: becomes minLength/maxLength on strings, minimum/maximum on numbers, minItems/maxItems on arrays
-
@format(name) — JSON Schema format (email, uri, date-time, etc.)
-
@default(value) / @default_for(:key) — static or user-specific defaults
-
@desc(text), @title(text) — JSON Schema annotation keywords
Class Method Summary collapse
-
.compile_input(handler_class, server_context:) ⇒ Object
Compile the input JSON Schema for a handler class, filtered for the current user’s permissions.
-
.compile_output(handler_class, server_context:) ⇒ Object
Compile the output JSON Schema for a handler class, filtered for the current user’s permissions.
-
.filter_input(handler_class, params, server_context:) ⇒ Hash
Filter incoming params against the user’s compiled input schema.
-
.filter_output(handler_class, result, server_context:) ⇒ Object
Filter the handler’s return value against the user’s compiled output schema.
-
.reset_cache! ⇒ Object
Clear all cached type maps and shared type caches.
-
.shared_type_cache ⇒ Object
Global cache for parsed shared
.rbsfiles. -
.strict_sanitize(schema) ⇒ Object
Strip JSON Schema keywords unsupported by Anthropic’s strict tool use mode, and add additionalProperties: false to all objects.
Class Method Details
.compile_input(handler_class, server_context:) ⇒ Object
Compile the input JSON Schema for a handler class, filtered for the current user’s permissions.
Supports two annotation styles:
-
# @rbs type input = { … } — an explicit record type
-
#:annotation above def call — inferred from method signature
: (untyped, server_context: untyped) -> Hash[Symbol, untyped]
55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
# File 'lib/mcp_authorization/rbs_schema_compiler.rb', line 55 def compile_input(handler_class, server_context:) cached = cache_for(handler_class) schema = if cached[:raw_input]&.dig(:kind) == :record compile_tagged_record(cached[:raw_input][:body], cached[:type_map], server_context) else build_input_schema( filter_call_signature(cached[:call_params], cached[:type_map], server_context) ) end schema = with_ref_injection(schema, cached[:type_map]) McpAuthorization.config.strict_schema ? strict_sanitize(schema) : schema end |
.compile_output(handler_class, server_context:) ⇒ Object
Compile the output JSON Schema for a handler class, filtered for the current user’s permissions.
: (untyped, server_context: untyped) -> Hash[Symbol, untyped]?
74 75 76 77 78 79 80 81 82 |
# File 'lib/mcp_authorization/rbs_schema_compiler.rb', line 74 def compile_output(handler_class, server_context:) cached = cache_for(handler_class) if cached[:raw_output]&.dig(:kind) == :union schema = compile_tagged_union(cached[:raw_output][:body], cached[:type_map], server_context) schema = with_ref_injection(schema, cached[:type_map]) return McpAuthorization.config.strict_schema ? strict_sanitize(schema) : schema end end |
.filter_input(handler_class, params, server_context:) ⇒ Hash
Filter incoming params against the user’s compiled input schema.
Any key that is not in the schema for this user is dropped — including @requires-gated fields the user lacks permission for, and any unknown fields not declared in the schema. This is the runtime enforcement counterpart to the input-shaping that compile_input did.
: (untyped, Hash[untyped, untyped], server_context: untyped) -> Hash[untyped, untyped]
96 97 98 99 100 101 |
# File 'lib/mcp_authorization/rbs_schema_compiler.rb', line 96 def filter_input(handler_class, params, server_context:) return params unless params.is_a?(Hash) schema = compile_input_for_filter(handler_class, server_context: server_context) return params unless schema project_against_schema(params, schema, defs_from(schema)) end |
.filter_output(handler_class, result, server_context:) ⇒ Object
Filter the handler’s return value against the user’s compiled output schema. Any field/variant not visible to this user is stripped from the result.
This is the runtime counterpart to compile_output: even if a handler bug or auth confusion causes it to emit fields the user shouldn’t see, they never cross the wire. Passes the result through unchanged if no @rbs type output is defined.
: (untyped, untyped, server_context: untyped) -> untyped
117 118 119 120 121 |
# File 'lib/mcp_authorization/rbs_schema_compiler.rb', line 117 def filter_output(handler_class, result, server_context:) schema = compile_output_for_filter(handler_class, server_context: server_context) return result unless schema project_against_schema(result, schema, defs_from(schema)) end |
.reset_cache! ⇒ Object
Clear all cached type maps and shared type caches. Called by the Engine’s reloader on code change in development so that modified annotations are re-parsed on the next request. : () -> void
187 188 189 190 |
# File 'lib/mcp_authorization/rbs_schema_compiler.rb', line 187 def reset_cache! @cache = {} @shared_type_cache = {} end |
.shared_type_cache ⇒ Object
Global cache for parsed shared .rbs files. Keyed by file path; each entry stores the file’s mtime so stale entries are recompiled when the file changes on disk.
: () -> Hash[String, untyped]
179 180 181 |
# File 'lib/mcp_authorization/rbs_schema_compiler.rb', line 179 def shared_type_cache @shared_type_cache ||= {} end |
.strict_sanitize(schema) ⇒ Object
Strip JSON Schema keywords unsupported by Anthropic’s strict tool use mode, and add additionalProperties: false to all objects. Converts oneOf to anyOf (strict mode supports anyOf but not oneOf).
: (Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 |
# File 'lib/mcp_authorization/rbs_schema_compiler.rb', line 128 def strict_sanitize(schema) return schema unless schema.is_a?(Hash) # Keywords that cause 400 in strict mode unsupported = %i[ minLength maxLength minimum maximum exclusiveMinimum exclusiveMaximum multipleOf maxItems uniqueItems dependentRequired deprecated readOnly writeOnly title examples contentMediaType contentEncoding ] result = {} schema.each do |key, value| next if unsupported.include?(key) result[key] = case key when :properties value.transform_values { |v| strict_sanitize(v) } when :items strict_sanitize(value) when :oneOf # strict mode supports anyOf but not oneOf result.delete(key) result[:anyOf] = value.map { |s| strict_sanitize(s) } next when :anyOf, :allOf value.map { |s| strict_sanitize(s) } when :minItems # strict mode only supports 0 and 1 value <= 1 ? value : nil when :"$defs" value.transform_values { |v| strict_sanitize(v) } else value end end # Strict mode requires additionalProperties: false on objects if result[:type] == "object" && result[:properties] && !result.key?(:additionalProperties) result[:additionalProperties] = false end result.compact end |