Releases
Every released version, what changed in it, and why.On this page
All notable changes to this gem are documented here. The format follows Keep a Changelog and this project adheres to Semantic Versioning.
[0.8.0]π
A supported seam for tool classes the host generates at runtime instead of defining in a file under tool_paths. Additive and opt-in β a host that sets no producers behaves exactly as before. (#35)
Addedπ
config.tool_producersβ an array of callables that register generated tool classes. Invoked byToolRegistry.ensure_tools_loaded!on the first read of a registry that has not finished loading, immediately after thetool_pathseager-load, and again after everyreset!. Defaults to[].Registering generated tools previously had no supported hook, so hosts improvised one from a Rails boot callback β and both available callbacks are traps. Registering before the gem's own load tripped
ensure_tools_loaded!'s oldreturn if @registered_tools&.any?guard and silently suppressed every file-defined tool, leaving a near-emptytools/listin any environment that doesn't eager-load. Registering fromconfig.to_prepareruns host code during:run_prepare_callbacks, which precedes:eager_load!and the railtieafter_initializethat copiesconfig.i18nontoI18nβ so application code loaded there sees an emptyI18n.load_path, and any class resolving a translation in its class body freezes"Translation missing: β¦"into its validators and option lists permanently, with the failures landing nowhere near MCP. Running producers from a registry read makes both unreachable by construction rather than by documentation.Boot-time registry population when the host eager-loads. The Engine reads the registry from
after_initializewhenconfig.eager_loadis on, so a malformed tool β or a producer that raises β fails the deploy rather than the firsttools/list. Development and test stay lazy.after_initializespecifically: it is the earliest phase where the framework is guaranteed to be fully configured,I18nincluded.
Changedπ
Loading completion is tracked separately from registry contents.
ensure_tools_loaded!previously short-circuited onreturn if @registered_tools&.any?, andregistered_toolsonly triggered a load while the array was empty. That conflated "the registry has entries" with "loading finished", which is wrong as soon as loading can fail partway:eager_load_tool_paths!registers the file-defined tools first, so by the time a producer raises the array is already non-empty β as it also is when a producer registers 40 tools and raises on the 41st. Every later read then no-op'd, so a bad producer failed loudly exactly once and silently forever after, leaving a permanently incomplete surface and contradicting the documented "exceptions propagate" contract. A dedicated@tools_loadedflag, set only after every producer returns, replaces both guards; a raising producer is now retried on every read until it is fixed. Re-running is safe βregisterdedupes by identity andeager_load_diris a no-op on an already-loaded directory.Two consequences worth naming. A host that calls
registerdirectly before the first read no longer suppresses thetool_pathsload β the silent-erasure hazard producers exist to remove is now unreachable from that direction too. Andreset!clears the flag, so a reload still reloads.
Fixedπ
ensure_tools_loaded!no longer raises on a partially-loaded Rails. The autoloader pass guarded on the bareRailsconstant and then calledRails.root/Rails.autoloaders. A process whereRailsis defined but incomplete (or an unrelated module of that name) satisfieddefined?and raisedNoMethodError, takingregistered_toolsdown with it β including for a host whose tools all come from producers and need no autoloader at all. It now probes for the methods it actually calls, matching thedefined?(Rails) && Rails.respond_to?(:env)idiom already used inDiagnostics.
Internalπ
ensure_tools_loaded!is reentrant: a producer may read the registry (to inspect what is already registered, say) without recursing forever. The reentrancy guard is cleared in anensure, so a raising producer does not wedge every later read.- The
tool_pathseager-load moved into a privateeager_load_tool_paths!, leavingensure_tools_loaded!as the ordering contract it documents.
[0.7.1]π
Follow-ups from the 0.7.0 review (onboardiq/mcp_authorization#31).
Fixedπ
- A facade
tools/callno longer recompiles every tool in the group.FacadeBuilder.facade_for(thetools/callrouting path) built the facade with the:vendor_extension_metapayload, which compiles the input schema of every tool in the group β but dispatch only needs the advertised name set, and nothing on the call path reads_meta. Over a 30-tool group that was ~30 extra per-tool compiles on every call, partially regressing the 0.6.0 "compile only the invoked tool" optimization on exactly the large domains facades target.build_facadenow takesfor_dispatch:(true fromfacade_for) and skips the_metamap;tools/list(facades_for) is unchanged and still carries it. facet_domain(group_by:)is now validated. Any value but:category(the only supported grouping key) raisesArgumentError, consistent withschema_strategy:/uncategorized:. Previously a typo (group_by: :tag) was silently accepted and behaved as:category.- Corrected a stale RBS annotation on
FacadeBuilder.facade_input_schema(declared 3 params for a 2-param method; the intervening prose comment also caused Sentinel to omit it from the generated sigs). Annotation fixed and moved adjacent to the def so it regenerates.
Addedπ
- Cache-vocabulary test for the facade path (design doc Β§8): asserts a cold compile of a faceted domain learns the consulted decisions, so two callers with different permission sets produce different
tools_list_keys (guarding against a shared-listing RBAC leak).
[0.7.0] - 2026-07-21π
Declarative tool grouping: a domain can present its tools as a small set of summarized category facades instead of a flat list, with per-tool schemas deferred out of the selection prompt. Opt-in and per-domain β domains not configured via facet_domain behave exactly as before. (#30)
Addedπ
category :nametool DSL. A tool declares the group it belongs to when its domain is faceted; ignored in flat domains. An optionalsummary:kwarg serves single-tool groups; the central registry wins on conflict.config.facet_domain :admin, group_by: :categoryβ present a domain as grouped facades.tools/listreturns one facade per group the caller has at least one permitted tool in (e.g.orders_tools), each with a routing-only description: the group summary plus RBAC-filtered one-liners of the tools the caller may actually invoke. Groups with zero permitted tools are hidden entirely, so a facade never advertises an emptyenum(which fails JSON Schema draft-04 validation and can fail the wholetools/list).config.categories { summary :orders, "..." }β one summary line per group, used as the facade description's lead.facet_domain(..., facade_suffix:)β override the token appended to a category to form its facade tool name. Defaults to"tools"(orders_tools); e.g.facade_suffix: "hire"exposesorders_hire. Must be a lowercase identifier fragment ([a-z0-9_]). The suffix is folded into the tools/list cache digest, so changing it invalidates cached listings.Deferred-schema strategy per domain via
schema_strategy:. The facadeinputSchemais always a flat object (tool_nameenum + permissivearguments): an LLM toolinput_schemamust have an object root β Anthropic and OpenAI rejectoneOf/allOf/anyOfat the top level β and hosts forward a facade'sinputSchemastraight to the model, so a correlated inline shape (eachtool_nametied to its argument schema) is not expressible and is not offered.:vendor_extension(default) carries the per-tool schemas on the facade's_meta(key"tool-input-schemas") β the MCP-sanctioned extension channel that SDKs preserve and that is never forwarded to the model asinput_schemaβ for a client that wants to expand the facade.:lazycarries names only; argument shapes are enforced at dispatch. In both strategies the per-tool schemas are compiled per caller, so permission-gated fields never appear in a facade a caller receives.Facade dispatch through the real call path. A
tools/callon a facade names the inner tool (tool_name) and itsarguments. Dispatch checks the name against the set advertised to this caller, re-resolves the tool viaToolRegistry.tool_class_forβ which re-runspermitted?, so gating is enforced even against a stale advertised set β and delegates to the tool's materializedcall. Input filtering, output filtering, andNotAuthorizedErrorbehave exactly as in a direct call, because it is the same code.Argument coercion against the target tool's schema. MCP clients frequently serialize nested objects as JSON strings; the facade's generic
arguments: objectcontract cannot know which fields to parse. Both theargumentsblob itself and any top-level value whose target schema type is an object or array are JSON-parsed before dispatch, then stripped by the target'sfilter_inputas usual.uncategorized:mode β a tool without acategoryin a faceted domain lands in anuncategorizedfallback group by default;uncategorized: :errorraises instead for servers that want CI-enforced completeness. A facade name that collides with a real registered tool raisesFacadeNameCollisionErrorrather than shadowing the tool.ToolRegistry.facades_for(domain:, server_context:)/facade_for(domain:, name:, server_context:)β the facade analogues oftool_classes_for/tool_class_for.McpControllerroutestools/liston a faceted domain to facades and resolves facade names ontools/call(direct tool names still resolve, so a client that learned a real tool name keeps working).
Changedπ
- The
tools/listcache defs digest now folds in facet configuration. Each tool'scategory, everyfacet_domainsetting, and every group summary participate in the digest, so toggling grouping, switching schema strategy, or rewording a summary invalidates cached listings the same way a gate or handler-source change does.
[0.6.2] - 2026-07-01π
Fixedπ
- A record field whose type is an inline string-literal union with a single field-level tag was misclassified as a per-member-tagged union.
compile_tagged_recordroutes a field intocompile_tagged_union(which gates each|-separated member individually, e.g.stage: a @feature(x) | b @feature(y)) whenevertagged_union_field?sees an@anywhere in the type string plus more than one|-separated part. A plain literal union with one field-level tag trailing the whole thing βlogic: "AND" | "OR" @desc(...)β matches that same heuristic even though the tag applies to the field, not an individual member. Misrouting sends each bare literal ("AND","OR") throughresolve_type, which only resolves named alias references; each literal fell back to{type: "object"}, producing{type: "object", oneOf: [{type: "object"}, {type: "object"}]}instead of{type: "string", enum: ["AND", "OR"]}.tagged_union_field?andtagged_array_union_innernow require at least one non-final|-separated member to carry a tag before treating a field as per-member-gated β every genuine per-member-tagged union in this codebase tags each gated member individually, so a tag trailing only the last member is never sufficient on its own. Field-level tags on inline literal unions now fall through to the normal RBS-library path (visit_rbs_union), which already resolved them correctly.
[0.6.1] - 2026-07-01π
Fixedπ
- Single-line string-literal-union type aliases (e.g.
type logic = "AND" | "OR") lost every member after the first. Both the# @rbs typeinline-comment parser and the sharedsig/shared/*.rbsfile parser only scanned subsequent lines for| "value"continuations, so a union written entirely on one line β the common shape for short enums like"AND" | "OR"β resolved to{type: "string", enum: ["AND"]}instead of["AND", "OR"]. Multi-line unions ("low"\n| "medium"\n| "high") were unaffected. Literal unions written directly inline on a record field (not behind a named alias) were also unaffected, since those go through the RBS-parser union visitor rather than the alias collector.
[0.6.0] - 2026-06-25π
Per-request work is now scoped to what each MCP method needs, and the remaining tools/list cost is cacheable.
Changedπ
McpController#handlenow materializes only the tools the incoming request needs. Every request βinitialize,notifications/initialized,ping, the GET stream probe, andtools/callβ previously ranToolRegistry.tool_classes_for, compiling a per-user schema for every tool in the domain before the transport even looked at the method. Per-tool schema compilation is the dominant cost of an MCP request, so atools/call(which invokes exactly one tool) and lifecycle traffic (which needs none) paid the full-domain price for nothing.handlenow routes by JSON-RPC method:tools/listmaterializes the whole domain (unchanged),tools/callmaterializes only the invoked tool, lifecycle methods and the non-POST probe materialize none, and any unrecognized shape (e.g. a JSON-RPC batch with no top-levelmethod) falls back to the full domain so routing stays correct. In a 140-tool domain this took atools/callfrom ~2.6s to <100ms andnotifications/initializedfrom ~2s to ~1ms, with no change totools/listoutput.
Addedπ
ToolRegistry.tool_class_for(domain:, name:, server_context:)β returns the concreteMCP::Toolsubclass for a single named tool within a domain (ornilwhen the tool is unknown in that domain or the current user is not permitted), materializing just that one tool instead of the whole domain. Complementstool_classes_for, which remains the path fortools/list.Opt-in caching for the
tools/listresponse.tools/listmust materialize a per-user schema for every tool in a domain β the dominant cost of an MCP request now thattools/callcompiles only the invoked tool (above). It can now be cached. Enable in the host initializer:McpAuthorization.configure do |c| c.tools_list_cache = :redis # or :memory, or any object responding to get/set c.tools_list_cache_ttl = 3600 # seconds (default) endDefault is no caching (
NullStore), so behavior is unchanged unless opted in.Decision-vector cache key β correct under feature flags, shareable across identity. The key is
H(domain + tool_defs_digest + vocab_fingerprint + decision_vector), never user/account identity. The decision vector is the result of every gating decision the domain's compilation consults β@requires/@feature/@tier/custom predicates, tool-levelgate/authorization, andcurrent_user.can?/default_for. Two contexts that answer all of them identically (same permissions, feature flags, tiers, defaults) share an entry; flip one feature flag and the vector β and the key β change, so an admin in a flag-on account never receives a flag-off account's tools. Thetool_defs_digest(computed from each tool's gates + handler source) changes on deploy, auto-invalidating stale entries; the TTL bounds out-of-band staleness.Two ways to supply the decision vector. Automatic: the gem learns a domain's predicate vocabulary by wrapping the context in a
Cache::Recorderon the first (cold) compile, then replays that vocabulary against the live context on subsequent requests. Explicit: if the server context responds tomcp_cache_fingerprint, its return value is used verbatim as the decision component (the host folds in whatever shapes the schema). Explicit wins when present.Pluggable stores.
Cache::NullStore(default),Cache::MemoryStore(process-local, bounded LRU + per-entry TTL), andCache::RedisStore(shared; JSON values; per-entry TTL). The Redis store's connection resolves from an explicit client (tools_list_cache_redis), thentools_list_cache_redis_url, thenENV["REDIS_URL"], then a bareRedis.newβ i.e. it defaults to the host's Rails redis config with no extra wiring.redisis an optional dependency, required lazily only when the Redis store is used. Cache outages fail open (a get/set error logs and behaves as a miss, never breakingtools/list).McpControllerservestools/listthrough the cache when enabled: a hit renders the cachedresultre-wrapped with the live JSON-RPC id; a miss compiles cold under aRecorder, learns the vocabulary, and stores the result. Error/unexpected responses are rendered but not cached. All other methods are unaffected.
Notesπ
- The cache is cleared on code reload (the Engine reloader now also calls
Cache.reset!), so development picks up tool/schema changes immediately.
[0.5.6] - 2026-06-08π
Fixedπ
- Single-line and column-aligned
# @rbs typerecord aliases are now collected. A record alias written on one line β# @rbs type ok = { a: String, b: Integer }β was silently dropped:collect_inline_aliasestruncated the body to a bare{and only a closing brace on a following line ever balanced it. Any union or field referencing such an alias resolved to the{type: "object"}fallback (no properties, no per-request gating), so the advertised schema and runtime projection both lost the type's shape. The opening-line body is now captured whole and stored immediately when its braces balance. Relatedly, the alias regex now tolerates arbitrary whitespace around=, so column-aligned blocks (# @rbs type success = { ... }) parse instead of being skipped. Multi-line aliases are unaffected.
[0.5.5] - 2026-06-04π
Fixedπ
$defsdeduplication now ref-injects inside hoisted defs and prunes unreferenced ones.with_ref_injectionhoisted a multi-use type into$defsand$refd it from the main schema, but left the def's body untouched β so a nested multi-use type (e.g. a shared base that inlines a template used elsewhere) stayed fully inlined inside the hoisted base and got hoisted again into a separate, never-referenced def. Each def body is now itself ref-injected (replacing nested multi-use types with$ref, excluding self), and any def left unreferenced (transitively from the root) is dropped. For a large discriminated union with a shared base, this removes both the duplicated nested types and the dead defs β a substantialtools/listsize reduction with identical semantics.
Addedπ
- Per-member predicate gating also reaches a union inside
Array[...]. A field typedArray[a @feature(x) | b @feature(y)]now gates the element union per request (the|is at bracket depth 1, so the plain field-union path didn't see it).compile_tagged_recorddetectsArray[<tagged union>], gates the inner union viacompile_tagged_union, and re-wraps it as{type: "array", items: β¦}. Lets a list-of-discriminated-variants input (e.g. bulk create) expose only the variants available to the current account/user. - Per-member predicate gating now works for a union nested in a record field. Previously, variant-level gating (
a | b @requires(:x)) only applied to a top-level# @rbs type output/inputunion; a union inside a record field (# @rbs type input = { stage: a @feature(x) | b @feature(y) }) went through the RBS-library path and couldn't carry per-member tags.compile_tagged_recordnow detects a multi-member union field that carries a predicate tag and routes it throughcompile_tagged_union, so each variant is filtered per request (variants whose predicate is false are dropped). Untagged union fields are unchanged (still the full RBS path, so inline records etc. keep working). This lets, e.g., a discriminated-union input expose only the variants available to the current account/user. - Record intersection (
type x = base & { ... }) compiles toallOf. A shared.rbstype alias may now intersect a base type with an inline record β e.g.type scheduler_stage = stage_common & { type: "SchedulerStage", ... }. The base resolves first and, because it appears identically across every intersection that uses it,with_ref_injectionhoists it into$defsonce and$refs it from each member. For a large discriminated union whose members share most of their fields, this collapses the duplicated common fields into a single$def(major token reduction intools/list) while keeping full per-type typing. Field-level&in RBS type expressions also maps toallOf. Runtime projection (filter_input/filter_output) flattensallOfmembers β merging base + ownproperties/requiredand resolving$refs β so discriminator-constmatching and field projection work through the intersection.
Fixedπ
- Output projection honors
constdiscriminators on union members. When a# @rbs type outputunion's members are tagged by a property pinned to a literal (e.g.type: "SchedulerStage"), runtime projection (filter_output) now selects the member whoseconstmatches the value's tag, rather than the member with the most incidental field overlap. A value whose tag matches no member falls through to the existing defensive pass-through (returned unchanged) instead of being mis-projected onto an unrelated variant and having its fields stripped. Non-discriminated unions (noconstproperties) are unaffected β they still pick the best-overlap variant. This makes large discriminated unions (one record per subtype, sharing a single tag field) project losslessly. - Shared types can now reference types defined in another imported file. A
# @rbs importedsig/shared/*.rbsfile may reference a type declared in a different imported file (as long as the handler imports both) β e.g. a per-type contract referencing a sharedmove_rule/templatealias. Previously each shared file was parsed and resolved in isolation, so a cross-file reference degraded to a fallback ({type: "object"}/{type: "string"}).build_cachenow collects the raw (unresolved) aliases from every imported file plus the handler's own inline# @rbs typedefinitions, merges them (local overrides imported), and resolves the whole set together, so cross-file references resolve. Within-file references and$defsdeduplication are unchanged.
Changedπ
- Shared-type import resolution no longer requires Rails.
resolve_import_pathnow resolves absoluteshared_type_pathsdirectly and falls back to the current working directory when Rails is absent, instead of returningnilwheneverRailsis undefined. Rails hosts using a relative path (e.g."sig/shared") are unaffected (still resolved againstRails.root); this makes shared imports usable β and testable β outside Rails.
[0.5.4] - 2026-06-04π
Fixedπ
Predicate gating (
@requires/@feature/@hidden/β¦) now recurses into nested record types. (#23)Per-request gating was only applied to the top-level fields of a handler's own input record /
#:params and to top-level output-union variants. A gated field inside a nested record alias (a# @rbs type foo = { ... }referenced asArray[foo], as a nested property, or imported fromsig/shared/*.rbs) was resolved from the statically-compiledtype_mapand never filtered againstserver_context, so it leaked into the schema for every user β silently defeating the field-shaping the DSL advertises. The compiler now threads a per-request resolution context (server_context+ the retained, tag-intact raw record bodies) through the type visitor: when a named record alias is referenced, it is recompiled withcompile_tagged_recordso its own predicate-gated fields are filtered at any nesting depth, including across imports. Avisitingstack guards against recursive types. Runtime enforcement (filter_input/filter_output) inherits the fix, since it projects against the same per-request schema. Predicate-free aliases still dedupe into$defsexactly as before.untypednow compiles to the empty schema{}("any value"), not{type: "string"}. (#22)untyped(RBSBases::Any/Void/Nil) emitted{type: "string"}despite the inline comment promising "no constraint". The most common casualty wasHash[K, untyped], which compiled to{type: "object", additionalProperties: {type: "string"}}β forcing every property value to be a string. A payload carrying a nested object or array under such a param listed fine intools/listbut was rejected server-side attools/call("β¦ did not match the following type: string").untypednow maps to{}, soHash[K, untyped]becomes{type: "object", additionalProperties: {}}(any value allowed) and bareuntypedbecomes{}. Relatedly,project_against_schemanow honorsadditionalProperties: an explicitly-open object (e.g. anuntypedhash) keeps its undeclared keys through runtime projection instead of being emptied before the handler runs, while objects with noadditionalProperties(oradditionalProperties: false, e.g.@closed) keep the closed-by-default projection that enforces@requiresgating.
[0.5.3] - 2026-06-03π
Fixedπ
#comments inside an RBS record type no longer break schema compilation. (#20)A comment line inside a record body β whether in an inline
# @rbs typeannotation or an importedsig/shared/*.rbsalias β raisedArgumentError: invalid field name token. The line-based readers (find_raw_type_body,parse_type_aliases,parse_rbs_file) concatenate record-body lines without a newline separator, so a comment folded into the next field name ("# a note describing the fields belowid"). Comments are valid anywhere in RBS β the official lexer discards#-to-end-of-line everywhere β so the readers now strip line comments before splitting fields, via a newstrip_rbs_commenthelper that leaves#inside string literals and bracketed annotation values (e.g.@desc(...)) untouched.Impact: because
tools/listmapsto_mcp_definitionover every tool in a domain, a single tool with an in-record comment took down discovery for the entire domain β and the offending comment could live far away in a shared.rbsalias that several tools import.Narrowed
rbsrequire set now loads underrbs4.x. The 0.5.2 narrowing (16-file subset instead ofrequire "rbs") was validated againstrbs3.x but raisedNameError: uninitialized constant RBS::AST::Rubyon a fresh install resolvingrbs4.x β the same 4.x that 0.5.2's loosened>= 3.0constraint explicitly allows.rbs4.x's C extension references the newRBS::AST::Ruby::*namespace and itsparser_auxreferencesPathnameat load time. The require block now loadspathnameand therbs/ast/ruby/*files when present (guarded byrescue LoadErrorsorbs3.x, which lacks them, is unaffected). Verified againstrbs3.10 and 4.0.
Addedπ
- CI workflow (
.github/workflows/ci.yml) runningbundle exec rake testacross Ruby 3.1β3.4 on push and pull request, plus asentinel checkjob that fails if the committedsig/generated/*.rbsdrift from the inline#:annotations.rbs-sentinelis now pinned as a development dependency so the signature formatting CI checks against is reproducible.
[0.5.2] - 2026-05-28π
Changedπ
- Narrowed the
rbsrequire set.require "rbs"pulled in ~144 files (CLI, environment loader, definition builder, prototype generators, stdlib type signatures, validator, resolver, ...) β none of which this gem touches. Replaced with a 16-file subset covering onlyRBS::Parser.parse_typeand theRBS::Types::*AST classes the schema visitor actually visits. Measured against this gem's load path: ~1.2 MB RSS vs ~15 MB, 18 files loaded vs 144. No behavior change β same parser, same AST. - Loosened the
rbsversion constraint from>= 3.0, < 4.0to>= 3.0. The upper bound locked consumers out ofrbs 4.xeven thoughRBS::Parser.parse_typeis part of rbs's stable surface. Consumers who already depend onrbs 4for their own Steep / type-check toolchain can now use this gem without a downgrade. If a future rbs major actually breaks our parser-API usage, we'll add the upper bound back at that point β not preemptively.
[0.5.1] - 2026-05-27π
Fixedπ
Tag values containing balanced parens, commas, or pipes no longer break the parser. (#15)
The historical regex parser used flat patterns (
[^)]*,[^,}]+, bare.split("|")) to find delimiters. These patterns are not bracket-aware β a fundamental limitation of regular expressions (balanced delimiters are not a regular language). Symptom: silent miscompilation when any tag value contained the delimiter character.Five call sites were affected, all with the same root cause:
extract_tagsβ@desc(foo (bar))truncated at the inner)compile_tagged_recordβ comma inside@desc(...)fragmented fieldsparse_record_typeβ same, for nested/aliased recordsparse_call_paramsβ flat.split(",")mistook commas inside@desc(...)AND commas inside generic types (Hash[Symbol, untyped]) for parameter separators- union-splitting in
compile_tagged_unionandrbs_type_to_json_schemaβ pipe inside@desc(...)split the union mid-value
All four now go through bracket-aware primitives that track
(),[],{}depth while scanning.Concrete impact: a field annotated
Integer @desc(The ID (NOT the question id)) @min(1)previously compiled to{type: "string", minLength: 1}(wrong type AND wrong constraint keyword). Now compiles to{type: "integer", description: "The ID (NOT the question id)", minimum: 1}.
Changedπ
Type-expression parsing now delegates to the official
rbsgem.rbs_type_to_json_schemapreviously dispatched via a regex case statement (when "String",when /\AArray\[(.+)\]\z/, etc.). It now callsRBS::Parser.parse_typeand walks the resulting AST through a small visitor (visit_rbs_type,visit_rbs_class_instance,visit_rbs_union,visit_rbs_record,visit_rbs_literal). This aligns the gem's runtime type interpretation with Steep's static interpretation β both now use the same parser, eliminating an entire class of silent divergence where the regex case statement misinterpreted types that Steep accepted.Side benefit: types that previously fell through to the
{type: "string"}fallback because the regex case statement didn't recognize them (e.g.Hash[K, V]becomes{type: "object", additionalProperties: β¦}instead of{type: "string"}) now have correct JSON Schema mappings. No external behavior change for the type expressions exercised by tools shipped before 0.5.1.
Addedπ
- Internal helpers:
find_at_depth_zero,split_at_depth_zero,peel_trailing_tag,find_matching_open_paren,each_field_in_record. Bracket-aware primitives used byextract_tagsand the record/union splitters. - Runtime dependency on
rbs(>= 3.0, < 4.0). Previously transitive via Steep dev dependency; now explicit because the production code path uses it.
[0.5.0] - 2026-05-26π
Changed (BREAKING)π
Prefix optional marker (
?key:) is now honored consistently across all three RBS parsers.The three sibling parsers handled optional-field markers inconsistently:
parse_call_paramsβ accepted only prefix?key:compile_tagged_recordβ accepted only suffixkey?:, silently treated prefix?key:as requiredparse_record_typeβ recognized neither form, silently treated all fields as required
The README documents prefix as canonical ("Prefix a param with
?to mark it optional"), so handlers that followed the docs got unexpectedly-required fields in their compiled schemas.Effect on consumers: any field declared with prefix
?key:in a# @rbs type input = { ... }record, a nested/aliased record (# @rbs type foo = { ?bar: ... }), or an inline record inside a#:signature is now correctly marked optional in the JSON Schema (omitted fromrequired). For a consuming monolith with ~616 such fields, the schema'srequiredarray shrinks accordingly and clients (e.g. LLMs producing tool calls) will no longer treat these fields as mandatory.If you relied on the buggy behavior (prefix marker silently making the field required), declare the field with no marker (
key:) to keep it required, or enforce presence inside#call.
Deprecatedπ
- Suffix optional marker (
key?:) is deprecated; will be removed in 0.6.0. Suffixkey?:continues to work in 0.5.0 (record types, call signatures, and nested aliased records) but now emits a singleKernel#warnper use withcategory: :deprecated. Silence withWarning[:deprecated] = falseorruby -W:no-deprecated. The warning embeds the handler's source-file path because the annotation is parsed as static text β the offending file is not on the Ruby call stack when the warning is emitted, souplevel:cannot surface it.
Addedπ
RbsSchemaCompiler.parse_field_name(raw, source_file: nil)β internal helper that turns a raw field-name token (everything before the:) into[clean_name, optional?]. Single source of truth for the three parsers. RaisesArgumentErroron malformed input (empty, bare"?", double-marked"?key?","??key","key??"). Tolerates whitespace around the marker (" ? key"β["key", true]).
Fixedπ
parse_record_typenow recognizes optional markers at all. Previously, nested aliased record types like# @rbs type foo = { ?bar: ... }produced schemas where every field landed inrequiredregardless of the marker. Caught only because the same bug existed in the sibling parsers under different shapes, hiding the test gap.
Migration notesπ
- No code changes required. Suffix
key?:annotations keep parsing; you'll see a deprecation warning per call site on first cache build of each handler. Migrate to prefix?key:at your own pace before 0.6.0. - If you've been relying on prefix
?key:being silently treated as required (the buggy behavior), audit your schemas: declared-optional fields that the handler still requires must be enforced inside#call, not by the schema. - The README example in the records section was updated to use prefix
?count: Integerto match the documented canonical form.
Notesπ
- The
fountain/monolithconsumer (gem's primary downstream) has a separate migration PR tracking the suffixβprefix rewrite for ~616 affected fields, includingsig/shared/option_bank_result.rbsand friends. That work is out of scope for this gem release and will land in the monolith repo once it bumps themcp_authorizationgem to 0.5.0+.
[0.4.0] - 2026-05-21π
Addedπ
Tool-level generic predicate gates.
Toolsubclasses can now declare any number ofgate :predicate_name, :valuecalls in addition toauthorization :perm. The gate is evaluated at request time by callingserver_context.{predicate_name}?(value); if any gate returns false, the tool is hidden fromtools/listand rejected fromtools/call. This is the tool-level counterpart of the field-level@predicate(:value)system introduced in 0.3.0 β same semantics, same fail-open + error-isolation behavior, same backward-compat fallback forgate :requires.class BulkSendSmsTool < McpAuthorization::Tool authorization :communications # RBAC permission (existing behavior preserved) gate :feature, :sms # hide tool unless current_account.sms_enabled? gate :requires, :super_user # extra check beyond authorization endMcpAuthorization::Diagnosticsmodule β shared helper for the development-mode "Did you mean?" warning previously duplicated between field-level (@predicate) and tool-level (gate) sites. Single Levenshtein implementation, single warning phrasing per call site (gate :feture, :smswarns "Did you mean gate :feature?",@feture(:sms)warns "Did you mean @feature?").
Changedπ
authorizationmigrated to the generic gate pipeline.authorization :permis now a convenience alias forgate :requires, :perm. The legacy dual-path inTool.permitted?(one branch for_permission, another for gates) is gone; there is one pipeline now. Mirrors the field-level migration done in 0.3.0 (#12), where@requireswas migrated through the same generic predicate pipeline rather than carrying its own special-cased branch._permissionremains exposed as before for introspection.permitted?(nil_context)now denies when gates are declared (previously crashed). A nil context reachingpermitted?is a programmer error; fail-closed avoids silently exposing the tool.
Migration notesπ
- No breaking changes for end users. Tools that use
authorization :permcontinue to work exactly as before β the only difference is the internal pipeline. - If you read
tool_class._permissionfor introspection, it still returns the declared symbol. - Existing field-level
@requires/@featuresemantics are unchanged.
[0.3.0] - 2026-05-14π
Addedπ
- Generic predicate tags. Any
@tag(:value)annotation not in the known constraint list becomes a predicate filter: the compiler callsserver_context.tag_name?(value)at schema compile time. If the predicate returns false, the field/variant is excluded from the JSON Schema. This makes the gem infinitely extensible β definefeature?,tier?,beta?, or any predicate on your server context without gem changes. - Backward-compat fallback for
@requires: if the server context lacks arequires?method, the compiler falls back toserver_context.current_user.can?(:flag)directly. No deploy-ordering constraint. - Error isolation: exceptions from individual predicates are rescued and logged. A single broken predicate no longer crashes the entire
tools/listresponse. - Development-mode warning with DidYouMean suggestion when a predicate method is not found on the server context (e.g.,
@feture(:x)warns "Did you mean @feature?").
Changedπ
@requiresis now handled through the generic predicate path. Thetags[:requires]key is removed;@requires(:flag)is stored only intags[:predicates]like any other predicate.predicate_excluded?replaces the three hardcodedcurrent_user.can?filter lines incompile_tagged_record,compile_tagged_union, andfilter_call_signature.- Configuration docs updated to describe the predicate protocol on server context objects.
Migration notesπ
- Consumers using
OpenStructas server context continue to work β@requiresfalls back tocurrent_user.can?. To use@featureor custom predicates, define the corresponding?methods on your server context. - If you read
tags[:requires]from parsed tag hashes (unlikely outside the gem), switch totags[:predicates].find { |p| p[:name] == "requires" }.
[0.2.1] - 2026-05-13π
Fixedπ
tools/callresponses now includestructuredContentwhen the tool declares anoutputSchemaviadynamic_contract. Previously the response carried only text content, which spec-compliant clients rejected as a validation error. (#6)RbsSchemaCompilernow finds the handler's source file when#callis wrapped viaModule#prepend(param coercion, instrumentation,ActiveSupport::Concern, tracing libraries). It walksUnboundMethod#super_methodpast prepended modules until the owner is the handler class itself, so# @rbs typeand#:annotations are read from the right file instead of raising a contract violation. (#8)
[0.2.0] - 2026-04-20π
Addedπ
RbsSchemaCompiler.filter_input(handler, params, server_context:)β projects inbound params onto the user's compiled input schema before the handler runs. Keys gated by@requiresthe user lacks, and any keys not declared in the schema at all, are dropped.RbsSchemaCompiler.filter_output(handler, result, server_context:)β projects the handler's return value onto the user's compiled output schema. HiddenoneOfvariants and their fields are stripped before serialization.
Changedπ
@requiresis now a security boundary, not just a hint to the LLM. Tool calls throughTool.calland the anonymous class produced byTool.materialize_forpipe params throughfilter_inputon the way in and results throughfilter_outputon the way out. A crafted JSON-RPC request that sends a gated param, and a handler that accidentally emits a gated output field, can no longer leak.- README updated to describe enforcement as a guarantee. Handler authors no longer have to remember to re-check
can?in every branch that touches a gated field β the schema is the boundary.
Migration notesπ
- If your handler's
#callquietly accepted params that weren't declared in the#:annotation, those will now arrive asnil/default values. Declare them (with@requiresif appropriate) or drop them. - If your handler's output included fields that weren't in
@rbs type output, those are now stripped. Add them to the output type definition if they should ship.
[0.1.1] - 2026-04-02π
Addedπ
- Added MIT license, homepage, author metadata.
[0.1.0] - 2026-04-01π
Addedπ
- Initial gem extraction from the monorepo. Rails engine,
RbsSchemaCompiler,Tool/ToolRegistry,DSLmixin,McpController.
Collected from CHANGELOG.md in the repository. Edit it there, not here.