mcp_authorization v0.7.1

RBS type syntax

The @rbs type comments compile to JSON Schema:

On this page

The @rbs type comments compile to JSON Schema:

# Primitives
# @rbs type x = String    -> { "type": "string" }
# @rbs type x = Integer   -> { "type": "integer" }
# @rbs type x = Float     -> { "type": "number" }
# @rbs type x = bool      -> { "type": "boolean" }
# @rbs type x = true      -> { "type": "boolean", "const": true }
# @rbs type x = false     -> { "type": "boolean", "const": false }

# String enums
# @rbs type status = "pending"
#                  | "active"
#                  | "closed"

# Records
# @rbs type result = {
#   success: bool,
#   message: String,
#   ?count: Integer
# }
# (?count is optional -- excluded from "required")

# Arrays
# @rbs type items = Array[String]

# Type references (resolved from local types and imports)
# @rbs type input = { id: String, status: status }

Every handler must declare @rbs type output. It may be a single record, a reference, or a union:

# @rbs type output = { id: String }        # inline record
# @rbs type output = applicant             # reference
# @rbs type output = success | error       # union

When a type appears more than once in a compiled schema, it is hoisted into $defs and referenced with $ref rather than inlined repeatedly. This is automatic โ€” nothing to declare.

Constraint and annotation tags๐Ÿ”—

Tag any field in a #: annotation or @rbs type record to add JSON Schema constraints. Tags are written as @tag(value) after the type:

#: (
#:   name: String                          @min(1) @max(100),
#:   email: String                         @format(email),
#:   age: Integer                          @min(0) @max(150),
#:   score: Float                          @exclusive_min(0) @exclusive_max(1.0),
#:   tags: Array[String]                   @min(1) @max(10) @unique(),
#:   quantity: Integer                     @multiple_of(5),
#:   ?timezone: String                     @default_for(:timezone),
#:   ?stage_id: String?                    @requires(:backward_routing) @depends_on(:workflow_id)
#: ) -> Hash[Symbol, untyped]

Value constraints:

TagApplies toJSON Schema
@min(n)String, Integer, Float, ArrayminLength, minimum, or minItems
@max(n)String, Integer, Float, ArraymaxLength, maximum, or maxItems
@exclusive_min(n)Integer, FloatexclusiveMinimum
@exclusive_max(n)Integer, FloatexclusiveMaximum
@multiple_of(n)Integer, FloatmultipleOf
@pattern(regex)Stringpattern
@format(name)Stringformat (e.g. email, uri, date-time)
@unique()ArrayuniqueItems: true

Metadata:

TagJSON SchemaPurpose
@desc(text)descriptionField description โ€” also used as tool-chaining hints for MCP clients
@title(text)titleHuman-readable title
@default(value)defaultDefault value (true, false, nil, numbers, strings)
@default_for(:key)defaultDynamic default resolved via current_user.default_for(:key)
@example(value)examplesExample value (repeat for multiple: @example(foo) @example(bar))
@deprecated()deprecated: trueMark as deprecated
@read_only()readOnly: trueRead-only field
@write_only()writeOnly: trueWrite-only field

Authorization & predicate filters:

TagPurpose
@requires(:flag)Field/variant excluded when server_context.requires?(:flag) returns false. Legacy fallback: if requires? is not defined, falls back to current_user.can?(:flag).
@feature(:flag)Field/variant excluded when server_context.feature?(:flag) returns false (account-level feature flags)
@depends_on(:field)Emits dependentRequired โ€” field only required when parent field is present

Any @tag(:value) not in the known constraint list above is a generic predicate filter. At schema compile time, the gem calls server_context.tag_name?(value) โ€” if it returns false, the field is excluded. If server_context doesn't respond to the method, the predicate is skipped (permissive).

This makes the gem infinitely extensible. Define any predicate on your server context:

# In your app's server context:
def requires?(flag) = current_user.can?(flag.to_sym)
def feature?(flag) = current_account.feature_enabled?(flag.to_s)
def tier?(name) = current_account.plan_tier?(name.to_s)
def beta?(flag) = current_account.beta_enrolled?(flag.to_s)

# In your handler:
#: (?status: "active" | "inactive" | "unlisted" @feature(:opening_status_v2)) -> output
#: (?force: bool @requires(:admin) @tier(:enterprise)) -> output

Multiple predicates on the same field are AND-ed โ€” all must pass for the field to appear.

Tool-level gates๐Ÿ”—

The same predicate vocabulary is available at the tool wrapper level via gate :predicate, :value:

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

gate is the tool-level counterpart of @predicate(:value) on a field. Semantics:

  • Calls server_context.{predicate_name}?(value) at request time.
  • All gates AND together with authorization. The tool is shown only when every check passes.
  • Fail-open when the predicate method is missing on the server context (warning logged in development).
  • gate :requires, :perm falls back to current_user.can?(:perm) when the context lacks a requires? method (matching the field-level backward-compat path).
  • Exceptions raised by a predicate are rescued and logged โ€” a broken predicate never crashes tools/list.

Niche:

TagJSON Schema
@closed() / @strict()additionalProperties: false
@media_type(type)contentMediaType (e.g. application/json)
@encoding(enc)contentEncoding (e.g. base64)

The @min / @max tags are type-aware: on strings they emit minLength/maxLength, on numbers minimum/maximum, and on arrays minItems/maxItems.

Where tags may appear๐Ÿ”—

PositionEffect
On a param in #:Constrains or gates that input field
On a field in an @rbs type recordConstrains or gates that output field
On a member of an @rbs type unionGates that whole output variant
On an inline literal union memberGates that individual member

A tag trailing a whole inline literal union applies to the field, not to the last member โ€” the compiler distinguishes the two by whether any non-final member carries a tag.

Multiline #: annotations๐Ÿ”—

The #: annotation above def call supports multiple lines. Each line starts with #::

#: (
#:   applicant_id: String       @desc(Use fetch_latest_applicant to find this),
#:   workflow_id: String,
#:   ?stage_id: String?         @requires(:backward_routing) @depends_on(:workflow_id),
#:   ?reason: String?           @requires(:backward_routing)
#: ) -> Hash[Symbol, untyped]
def call(applicant_id:, workflow_id:, stage_id: nil, reason: nil)

Prefix a param with ? to mark it optional. Suffix the type with ? for nilable types. Both together (?name: Type?) means the field is optional and can be nil.

@depends_on for conditional required fields๐Ÿ”—

Use @depends_on(:parent_field) to express that a field is only required when another field is present. This emits JSON Schema dependentRequired:

#: (
#:   workflow_id: String,
#:   ?stage_id: String?      @requires(:backward_routing) @depends_on(:workflow_id),
#:   ?reason: String?        @requires(:backward_routing) @depends_on(:stage_id)
#: ) -> Hash[Symbol, untyped]

When :backward_routing is enabled, the schema includes:

{
  "dependentRequired": {
    "workflow_id": ["stage_id"],
    "stage_id": ["reason"]
  }
}

Discriminated unions๐Ÿ”—

Literal true / false types become "const" values in JSON Schema:

# @rbs type success = { success: true, data: String }
# @rbs type error   = { success: false, code: String }
# @rbs type output  = success | error

MCP clients can narrow on success: const true vs success: const false -- the same pattern as TypeScript discriminated unions.

Collected from README.md in the repository. Edit it there, not here.