Class: McpAuthorization::RbsSchemaCompiler

Inherits:
Object
  • Object
show all
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

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:

  1. # @rbs type input = { … } — an explicit record type

  2. #: 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]

Parameters:

  • handler_class (Class)
  • params (Hash)

    Params as received from the MCP client.

  • server_context (Object)

    Per-request context.

Returns:

  • (Hash)

    Filtered params safe to pass to the handler.



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

Parameters:

  • handler_class (Class)
  • result (Object)

    Handler return value (hash/array/primitive).

  • server_context (Object)

    Per-request context.

Returns:

  • (Object)

    Projected result, matching the user’s output schema.



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_cacheObject

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