RBS type syntax
The @rbs type comments compile to JSON Schema:
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 # unionWhen 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:
| Tag | Applies to | JSON Schema |
|---|---|---|
@min(n) | String, Integer, Float, Array | minLength, minimum, or minItems |
@max(n) | String, Integer, Float, Array | maxLength, maximum, or maxItems |
@exclusive_min(n) | Integer, Float | exclusiveMinimum |
@exclusive_max(n) | Integer, Float | exclusiveMaximum |
@multiple_of(n) | Integer, Float | multipleOf |
@pattern(regex) | String | pattern |
@format(name) | String | format (e.g. email, uri, date-time) |
@unique() | Array | uniqueItems: true |
Metadata:
| Tag | JSON Schema | Purpose |
|---|---|---|
@desc(text) | description | Field description โ also used as tool-chaining hints for MCP clients |
@title(text) | title | Human-readable title |
@default(value) | default | Default value (true, false, nil, numbers, strings) |
@default_for(:key) | default | Dynamic default resolved via current_user.default_for(:key) |
@example(value) | examples | Example value (repeat for multiple: @example(foo) @example(bar)) |
@deprecated() | deprecated: true | Mark as deprecated |
@read_only() | readOnly: true | Read-only field |
@write_only() | writeOnly: true | Write-only field |
Authorization & predicate filters:
| Tag | Purpose |
|---|---|
@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)) -> outputMultiple 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
endgate 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, :permfalls back tocurrent_user.can?(:perm)when the context lacks arequires?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:
| Tag | JSON 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๐
| Position | Effect |
|---|---|
On a param in #: | Constrains or gates that input field |
On a field in an @rbs type record | Constrains or gates that output field |
On a member of an @rbs type union | Gates that whole output variant |
| On an inline literal union member | Gates 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 | errorMCP 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.