mcp_authorization 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1015 @@
1
+ module McpAuthorization
2
+ # Compiles RBS-style type annotations in Ruby source files into JSON Schema,
3
+ # with per-request filtering based on +@requires+ permission tags.
4
+ #
5
+ # This is the heart of the schema-shaping authorization approach. Rather
6
+ # than defining JSON Schema separately, handler authors annotate their
7
+ # Ruby source files with RBS-style comments:
8
+ #
9
+ # # @rbs type output = success | admin_detail @requires(:admin)
10
+ #
11
+ # #: (name: String, ?force: bool @requires(:admin)) -> Hash[Symbol, untyped]
12
+ # def call(name:, force: false)
13
+ #
14
+ # The compiler parses these annotations *once* and caches the result.
15
+ # On each request, only the +@requires+ filtering runs — checking which
16
+ # fields/variants the current user can see and building a tailored schema.
17
+ #
18
+ # == Two-phase design
19
+ #
20
+ # *Parse phase* (cached, runs once per handler class):
21
+ # - Locate the handler's source file via +Method#source_location+
22
+ # - Load shared types from +# @rbs import+ statements
23
+ # - Parse local +# @rbs type+ definitions into a type map
24
+ # - Parse the +#:+ annotation above +def call+ into parameter descriptors
25
+ #
26
+ # *Compile phase* (per-request):
27
+ # - Filter parameters/variants by +@requires+ tags against +current_user.can?+
28
+ # - Apply constraint tags (+@min+, +@format+, etc.) to JSON Schema keywords
29
+ # - Inject +$ref/$defs+ when named types appear more than once (saves space)
30
+ #
31
+ # == Supported annotation tags
32
+ #
33
+ # See +extract_tags+ for the full list. Key tags:
34
+ # - +@requires(:flag)+ — field is omitted from schema if user lacks this permission
35
+ # - +@min(n)+, +@max(n)+ — type-aware: becomes minLength/maxLength on strings,
36
+ # minimum/maximum on numbers, minItems/maxItems on arrays
37
+ # - +@format(name)+ — JSON Schema format (email, uri, date-time, etc.)
38
+ # - +@default(value)+ / +@default_for(:key)+ — static or user-specific defaults
39
+ # - +@desc(text)+, +@title(text)+ — JSON Schema annotation keywords
40
+ #
41
+ class RbsSchemaCompiler
42
+ class << self
43
+ # ---------------------------------------------------------------
44
+ # Public API
45
+ # ---------------------------------------------------------------
46
+
47
+ # Compile the input JSON Schema for a handler class, filtered for the
48
+ # current user's permissions.
49
+ #
50
+ # Supports two annotation styles:
51
+ # 1. +# @rbs type input = { ... }+ — an explicit record type
52
+ # 2. +#:+ annotation above +def call+ — inferred from method signature
53
+ #
54
+ #: (untyped, server_context: untyped) -> Hash[Symbol, untyped]
55
+ def compile_input(handler_class, server_context:)
56
+ cached = cache_for(handler_class)
57
+
58
+ schema = if cached[:raw_input]&.dig(:kind) == :record
59
+ compile_tagged_record(cached[:raw_input][:body], cached[:type_map], server_context)
60
+ else
61
+ build_input_schema(
62
+ filter_call_signature(cached[:call_params], cached[:type_map], server_context)
63
+ )
64
+ end
65
+
66
+ schema = with_ref_injection(schema, cached[:type_map])
67
+ McpAuthorization.config.strict_schema ? strict_sanitize(schema) : schema
68
+ end
69
+
70
+ # Compile the output JSON Schema for a handler class, filtered for
71
+ # the current user's permissions.
72
+ #
73
+ #: (untyped, server_context: untyped) -> Hash[Symbol, untyped]?
74
+ def compile_output(handler_class, server_context:)
75
+ cached = cache_for(handler_class)
76
+
77
+ if cached[:raw_output]&.dig(:kind) == :union
78
+ schema = compile_tagged_union(cached[:raw_output][:body], cached[:type_map], server_context)
79
+ schema = with_ref_injection(schema, cached[:type_map])
80
+ return McpAuthorization.config.strict_schema ? strict_sanitize(schema) : schema
81
+ end
82
+ end
83
+
84
+ # Strip JSON Schema keywords unsupported by Anthropic's strict tool
85
+ # use mode, and add additionalProperties: false to all objects.
86
+ # Converts oneOf to anyOf (strict mode supports anyOf but not oneOf).
87
+ #
88
+ #: (Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
89
+ def strict_sanitize(schema)
90
+ return schema unless schema.is_a?(Hash)
91
+
92
+ # Keywords that cause 400 in strict mode
93
+ unsupported = %i[
94
+ minLength maxLength minimum maximum
95
+ exclusiveMinimum exclusiveMaximum multipleOf
96
+ maxItems uniqueItems
97
+ dependentRequired deprecated readOnly writeOnly
98
+ title examples contentMediaType contentEncoding
99
+ ]
100
+
101
+ result = {}
102
+ schema.each do |key, value|
103
+ next if unsupported.include?(key)
104
+
105
+ result[key] = case key
106
+ when :properties
107
+ value.transform_values { |v| strict_sanitize(v) }
108
+ when :items
109
+ strict_sanitize(value)
110
+ when :oneOf
111
+ # strict mode supports anyOf but not oneOf
112
+ result.delete(key)
113
+ result[:anyOf] = value.map { |s| strict_sanitize(s) }
114
+ next
115
+ when :anyOf, :allOf
116
+ value.map { |s| strict_sanitize(s) }
117
+ when :minItems
118
+ # strict mode only supports 0 and 1
119
+ value <= 1 ? value : nil
120
+ when :"$defs"
121
+ value.transform_values { |v| strict_sanitize(v) }
122
+ else
123
+ value
124
+ end
125
+ end
126
+
127
+ # Strict mode requires additionalProperties: false on objects
128
+ if result[:type] == "object" && result[:properties] && !result.key?(:additionalProperties)
129
+ result[:additionalProperties] = false
130
+ end
131
+
132
+ result.compact
133
+ end
134
+
135
+ # Global cache for parsed shared +.rbs+ files. Keyed by file path;
136
+ # each entry stores the file's mtime so stale entries are recompiled
137
+ # when the file changes on disk.
138
+ #
139
+ #: () -> Hash[String, untyped]
140
+ def shared_type_cache
141
+ @shared_type_cache ||= {}
142
+ end
143
+
144
+ # Clear all cached type maps and shared type caches. Called by the
145
+ # Engine's reloader on code change in development so that modified
146
+ # annotations are re-parsed on the next request.
147
+ #: () -> void
148
+ def reset_cache!
149
+ @cache = {}
150
+ @shared_type_cache = {}
151
+ end
152
+
153
+ private
154
+
155
+ # ---------------------------------------------------------------
156
+ # Tag extraction — unified parser for all @tag(...) annotations
157
+ # ---------------------------------------------------------------
158
+
159
+ # Extract all +@tag(value)+ annotations from a type string.
160
+ #
161
+ # Annotations are parsed right-to-left from the end of the string,
162
+ # peeling off one +@tag(...)+ at a time until none remain. This
163
+ # returns the clean RBS type (without tags) and a hash of parsed
164
+ # tag values.
165
+ #
166
+ # @example
167
+ # extract_tags("String @min(1) @max(100)")
168
+ # #=> ["String", { min: 1, max: 100 }]
169
+ #
170
+ # extract_tags("bool @requires(:admin)")
171
+ # #=> ["bool", { requires: :admin }]
172
+ #
173
+ # @param type_str [String] An RBS type string, possibly with trailing tags.
174
+ # @return [Array(String, Hash)] +[clean_type, tags_hash]+
175
+ #
176
+ # Supported tags:
177
+ # @requires(:symbol) -> { requires: :symbol }
178
+ # @depends_on(:field) -> { depends_on: "field" }
179
+ # @min(n) -> { min: n }
180
+ # @max(n) -> { max: n }
181
+ # @exclusive_min(n) -> { exclusive_min: n }
182
+ # @exclusive_max(n) -> { exclusive_max: n }
183
+ # @multiple_of(n) -> { multiple_of: n }
184
+ # @pattern(regex) -> { pattern: "regex" }
185
+ # @format(name) -> { format: "name" }
186
+ # @default(value) -> { default: value }
187
+ # @default_for(:key) -> { default_for: :key } (resolved via current_user.default_for)
188
+ # @desc(text) -> { desc: "text" }
189
+ # @title(text) -> { title: "text" }
190
+ # @example(value) -> { examples: [value, ...] }
191
+ # @deprecated() -> { deprecated: true }
192
+ # @read_only() -> { read_only: true }
193
+ # @write_only() -> { write_only: true }
194
+ # @unique() -> { unique: true }
195
+ # @closed() / @strict() -> { closed: true }
196
+ # @media_type(type) -> { media_type: "type" }
197
+ # @encoding(enc) -> { encoding: "enc" }
198
+ #: (String) -> [String, Hash[Symbol, untyped]]
199
+ def extract_tags(type_str)
200
+ tags = {}
201
+
202
+ # Extract all @tag(...) annotations from right to left
203
+ while type_str =~ /\A(.+?)\s+@(\w+)\(([^)]*)\)\s*\z/
204
+ type_str, tag_name, tag_value = $1.to_s.strip, $2.to_s, $3.to_s
205
+
206
+ case tag_name
207
+ when "requires"
208
+ tags[:requires] = tag_value.delete_prefix(":").to_sym
209
+ when "depends_on"
210
+ tags[:depends_on] = tag_value.delete_prefix(":")
211
+ when "min"
212
+ tags[:min] = tag_value.include?(".") ? tag_value.to_f : tag_value.to_i
213
+ when "max"
214
+ tags[:max] = tag_value.include?(".") ? tag_value.to_f : tag_value.to_i
215
+ when "exclusive_min"
216
+ tags[:exclusive_min] = tag_value.include?(".") ? tag_value.to_f : tag_value.to_i
217
+ when "exclusive_max"
218
+ tags[:exclusive_max] = tag_value.include?(".") ? tag_value.to_f : tag_value.to_i
219
+ when "multiple_of"
220
+ tags[:multiple_of] = tag_value.include?(".") ? tag_value.to_f : tag_value.to_i
221
+ when "pattern"
222
+ tags[:pattern] = tag_value
223
+ when "format"
224
+ tags[:format] = tag_value
225
+ when "default"
226
+ tags[:default] = parse_default_value(tag_value)
227
+ when "default_for"
228
+ tags[:default_for] = tag_value.delete_prefix(":").to_sym
229
+ when "desc"
230
+ tags[:desc] = tag_value
231
+ when "title"
232
+ tags[:title] = tag_value
233
+ when "example"
234
+ (tags[:examples] ||= []) << parse_default_value(tag_value)
235
+ when "deprecated"
236
+ tags[:deprecated] = true
237
+ when "read_only"
238
+ tags[:read_only] = true
239
+ when "write_only"
240
+ tags[:write_only] = true
241
+ when "unique"
242
+ tags[:unique] = true
243
+ when "closed", "strict"
244
+ tags[:closed] = true
245
+ when "media_type"
246
+ tags[:media_type] = tag_value
247
+ when "encoding"
248
+ tags[:encoding] = tag_value
249
+ end
250
+ end
251
+
252
+ [type_str, tags]
253
+ end
254
+
255
+ # Coerce a default value string from an annotation into its Ruby type.
256
+ # Handles booleans, nil/null, integers, floats, and bare strings.
257
+ #
258
+ # @param value [String] Raw value from +@default(...)+ or +@example(...)+.
259
+ # @return [Object] Coerced Ruby value.
260
+ #: (String) -> untyped
261
+ def parse_default_value(value)
262
+ case value
263
+ when "true" then true
264
+ when "false" then false
265
+ when "nil", "null" then nil
266
+ when /\A-?\d+\z/ then value.to_i
267
+ when /\A-?\d+\.\d+\z/ then value.to_f
268
+ else value.delete('"').delete("'")
269
+ end
270
+ end
271
+
272
+ # Map a parsed tag hash onto JSON Schema keywords in a schema hash.
273
+ #
274
+ # This is *type-aware*: +@min(5)+ becomes +minLength: 5+ on a string,
275
+ # +minimum: 5+ on an integer, and +minItems: 5+ on an array. This lets
276
+ # handler authors use a single annotation vocabulary regardless of the
277
+ # underlying JSON Schema type.
278
+ #
279
+ # @param schema [Hash] JSON Schema hash (must already have +:type+ set).
280
+ # @param tags [Hash] Parsed tags from +extract_tags+.
281
+ # @param server_context [Object, nil] Needed to resolve +@default_for+ tags.
282
+ # @return [Hash] The same schema hash, mutated with additional keywords.
283
+ #: (Hash[Symbol, untyped], Hash[Symbol, untyped], ?server_context: untyped?) -> Hash[Symbol, untyped]
284
+ def apply_tags(schema, tags, server_context: nil)
285
+ # Type-aware min/max
286
+ if tags[:min]
287
+ case schema[:type]
288
+ when "string" then schema[:minLength] = tags[:min]
289
+ when "integer", "number" then schema[:minimum] = tags[:min]
290
+ when "array" then schema[:minItems] = tags[:min]
291
+ end
292
+ end
293
+ if tags[:max]
294
+ case schema[:type]
295
+ when "string" then schema[:maxLength] = tags[:max]
296
+ when "integer", "number" then schema[:maximum] = tags[:max]
297
+ when "array" then schema[:maxItems] = tags[:max]
298
+ end
299
+ end
300
+
301
+ # Numeric constraints
302
+ schema[:exclusiveMinimum] = tags[:exclusive_min] if tags[:exclusive_min]
303
+ schema[:exclusiveMaximum] = tags[:exclusive_max] if tags[:exclusive_max]
304
+ schema[:multipleOf] = tags[:multiple_of] if tags[:multiple_of]
305
+
306
+ # String constraints
307
+ schema[:pattern] = tags[:pattern] if tags[:pattern]
308
+ schema[:format] = tags[:format] if tags[:format]
309
+
310
+ # Array constraints
311
+ schema[:uniqueItems] = true if tags[:unique]
312
+
313
+ # Annotation keywords
314
+ schema[:title] = tags[:title] if tags[:title]
315
+ schema[:description] = tags[:desc] if tags[:desc]
316
+ schema[:examples] = tags[:examples] if tags[:examples]
317
+ if tags[:default_for] && server_context
318
+ val = server_context.current_user.default_for(tags[:default_for])
319
+ schema[:default] = val unless val.nil?
320
+ elsif tags.key?(:default)
321
+ schema[:default] = tags[:default]
322
+ end
323
+ schema[:deprecated] = true if tags[:deprecated]
324
+ schema[:readOnly] = true if tags[:read_only]
325
+ schema[:writeOnly] = true if tags[:write_only]
326
+
327
+ # Niche constraints
328
+ schema[:additionalProperties] = false if tags[:closed]
329
+ schema[:contentMediaType] = tags[:media_type] if tags[:media_type]
330
+ schema[:contentEncoding] = tags[:encoding] if tags[:encoding]
331
+
332
+ schema
333
+ end
334
+
335
+ # ---------------------------------------------------------------
336
+ # Cache — source files are parsed once; per-request work is only
337
+ # the @requires filtering and tag application.
338
+ # ---------------------------------------------------------------
339
+
340
+ # Return the cached parse result for a handler class, building it
341
+ # on first access.
342
+ #
343
+ # @param handler_class [Class]
344
+ # @return [Hash] with keys +:type_map+, +:raw_input+, +:raw_output+,
345
+ # +:call_params+, +:source_file+.
346
+ #: (untyped) -> Hash[Symbol, untyped]
347
+ def cache_for(handler_class)
348
+ cache = (@cache ||= {})
349
+ cache[handler_class] ||= build_cache(handler_class)
350
+ end
351
+
352
+ # Parse a handler class's source file and build the type map and
353
+ # parameter descriptors that the compile phase uses.
354
+ #
355
+ # The type map is built in two layers:
356
+ # 1. Shared types from +# @rbs import+ statements (e.g. common enums)
357
+ # 2. Local +# @rbs type+ definitions in the handler file (override shared)
358
+ #
359
+ # @param handler_class [Class]
360
+ # @return [Hash]
361
+ #: (untyped) -> Hash[Symbol, untyped]
362
+ def build_cache(handler_class)
363
+ source_file = find_source_file(handler_class)
364
+ content = source_file && File.exist?(source_file) ? File.read(source_file) : ""
365
+
366
+ # Build type map: shared imports first, then handler's own types override
367
+ imported = load_imports(content)
368
+ local = parse_type_aliases(content)
369
+ type_map = imported.merge(local)
370
+
371
+ {
372
+ type_map: type_map,
373
+ raw_input: find_raw_type_body(content, "input"),
374
+ raw_output: find_raw_type_body(content, "output"),
375
+ call_params: parse_call_params(content),
376
+ source_file: source_file
377
+ }
378
+ end
379
+
380
+ # ---------------------------------------------------------------
381
+ # @requires filtering — the per-request compile phase
382
+ # ---------------------------------------------------------------
383
+
384
+ # Compile a record-style input type (+# @rbs type input = { ... }+)
385
+ # with field-level +@requires+ filtering.
386
+ #
387
+ # Fields whose +@requires+ flag the current user lacks are silently
388
+ # omitted from the resulting JSON Schema, so the LLM never sees them.
389
+ #
390
+ # @param raw_body [String] The raw record body, e.g. +"{name: String, force: bool @requires(:admin)}"+.
391
+ # @param type_map [Hash] Resolved type definitions for +$ref+ lookups.
392
+ # @param server_context [Object] Per-request context with +current_user+.
393
+ # @return [Hash] JSON Schema object with +properties+, +required+, etc.
394
+ #: (String, Hash[String, Hash[Symbol, untyped]], untyped) -> Hash[Symbol, untyped]
395
+ def compile_tagged_record(raw_body, type_map, server_context)
396
+ properties = {}
397
+ required = []
398
+ dependent_required = {}
399
+
400
+ inner = raw_body.strip.sub(/\A\{/, "").sub(/\}\z/, "").strip
401
+
402
+ inner.scan(/(\w+\??)\s*:\s*([^,}]+)/) do |match|
403
+ key, type_str = match[0].to_s, match[1].to_s
404
+ type_str, tags = extract_tags(type_str.strip)
405
+
406
+ next if tags[:requires] && !server_context.current_user.can?(tags[:requires])
407
+
408
+ optional = key.end_with?("?")
409
+ clean_key = key.delete_suffix("?")
410
+
411
+ schema = rbs_type_to_json_schema(type_str, type_map)
412
+ properties[clean_key.to_sym] = apply_tags(schema, tags, server_context: server_context)
413
+ required << clean_key unless optional
414
+
415
+ if tags[:depends_on] && properties.key?(tags[:depends_on].to_sym)
416
+ dependent_required[tags[:depends_on]] ||= []
417
+ dependent_required[tags[:depends_on]] << clean_key
418
+ end
419
+ end
420
+
421
+ schema = { type: "object", properties: properties } #: Hash[Symbol, untyped]
422
+ schema[:required] = required if required.any?
423
+ schema[:dependentRequired] = dependent_required if dependent_required.any?
424
+ schema
425
+ end
426
+
427
+ # Compile a union-style output type (+# @rbs type output = success | admin_detail @requires(:admin)+)
428
+ # with variant-level +@requires+ filtering.
429
+ #
430
+ # Each union variant (separated by +|+) can carry its own +@requires+
431
+ # tag. Variants the user lacks permission for are dropped entirely.
432
+ # If only one variant remains, it's returned directly (no +oneOf+
433
+ # wrapper). If zero remain, a bare +{type: "object"}+ fallback is used.
434
+ #
435
+ # @param raw_expr [String] The raw union expression.
436
+ # @param type_map [Hash] Resolved type definitions.
437
+ # @param server_context [Object] Per-request context.
438
+ # @return [Hash] JSON Schema — either a single schema or a +oneOf+ wrapper.
439
+ #: (String, Hash[String, Hash[Symbol, untyped]], untyped) -> Hash[Symbol, untyped]
440
+ def compile_tagged_union(raw_expr, type_map, server_context)
441
+ parts = raw_expr.split("|").map(&:strip).reject(&:empty?)
442
+
443
+ filtered = parts.filter_map do |part|
444
+ part, tags = extract_tags(part)
445
+ next nil if tags[:requires] && !server_context.current_user.can?(tags[:requires])
446
+ resolve_type(part, type_map)
447
+ end
448
+
449
+ case filtered.size
450
+ when 0 then { type: "object" }
451
+ when 1 then filtered.first
452
+ else { type: "object", oneOf: filtered }
453
+ end
454
+ end
455
+
456
+ # Filter method-signature parameters by +@requires+ tags and build
457
+ # the input JSON Schema. This is the path used when the handler defines
458
+ # its schema via a +#:+ annotation above +def call+ rather than an
459
+ # explicit +# @rbs type input = { ... }+.
460
+ #
461
+ # @param call_params [Array<Hash>] Parsed parameter descriptors from +parse_call_params+.
462
+ # @param type_map [Hash] Resolved type definitions.
463
+ # @param server_context [Object] Per-request context.
464
+ # @return [Hash] Partial JSON Schema (+properties+, +required+, etc.).
465
+ #: (Array[Hash[Symbol, untyped]], Hash[String, Hash[Symbol, untyped]], untyped) -> Hash[Symbol, untyped]
466
+ def filter_call_signature(call_params, type_map, server_context)
467
+ properties = {}
468
+ required = []
469
+ dependent_required = {}
470
+
471
+ call_params.each do |param|
472
+ if param[:tags][:requires] && server_context
473
+ next unless server_context.current_user.can?(param[:tags][:requires])
474
+ end
475
+
476
+ schema = rbs_type_to_json_schema(param[:type], type_map)
477
+ properties[param[:name].to_sym] = apply_tags(schema, param[:tags], server_context: server_context)
478
+ required << param[:name] if param[:required]
479
+
480
+ if param[:tags][:depends_on] && properties.key?(param[:tags][:depends_on].to_sym)
481
+ dependent_required[param[:tags][:depends_on]] ||= []
482
+ dependent_required[param[:tags][:depends_on]] << param[:name]
483
+ end
484
+ end
485
+
486
+ schema = { properties: properties } #: Hash[Symbol, untyped]
487
+ schema[:required] = required if required.any?
488
+ schema[:dependentRequired] = dependent_required if dependent_required.any?
489
+ schema
490
+ end
491
+
492
+ # ---------------------------------------------------------------
493
+ # Source parsing — cached, runs once per handler class
494
+ # ---------------------------------------------------------------
495
+
496
+ # Scan handler source for +# @rbs import+ lines and load the
497
+ # referenced shared +.rbs+ files into a merged type map.
498
+ #
499
+ # Import paths are resolved relative to the configured
500
+ # +shared_type_paths+ directories (default: +sig/shared/+).
501
+ #
502
+ # @example In a handler file:
503
+ # # @rbs import common_types
504
+ # # → loads sig/shared/common_types.rbs
505
+ #
506
+ # @param content [String] Full source file contents.
507
+ # @return [Hash{String => Hash}] Type name → JSON Schema map.
508
+ #: (String) -> Hash[String, Hash[Symbol, untyped]]
509
+ def load_imports(content)
510
+ return {} if content.empty?
511
+
512
+ imports = content.scan(/# @rbs import (\S+)/).flatten
513
+ return {} if imports.empty?
514
+
515
+ type_map = {}
516
+ imports.each do |import_path|
517
+ rbs_file = resolve_import_path(import_path)
518
+ next unless rbs_file && File.exist?(rbs_file)
519
+ type_map.merge!(cached_parse_rbs_file(rbs_file))
520
+ end
521
+ type_map
522
+ end
523
+
524
+ # Parse a shared +.rbs+ file with mtime-based caching. If the file
525
+ # hasn't changed since the last parse, the cached result is returned.
526
+ #
527
+ # @param path [String] Absolute path to the +.rbs+ file.
528
+ # @return [Hash{String => Hash}] Type name → JSON Schema map.
529
+ #: (String) -> Hash[String, Hash[Symbol, untyped]]
530
+ def cached_parse_rbs_file(path)
531
+ mtime = File.mtime(path)
532
+ cached = shared_type_cache[path]
533
+
534
+ if cached && cached[:mtime] == mtime
535
+ return cached[:result]
536
+ end
537
+
538
+ result = parse_rbs_file(path)
539
+ shared_type_cache[path] = { mtime: mtime, result: result }
540
+ result
541
+ end
542
+
543
+ # Resolve a bare import name (e.g. +"common_types"+) to an absolute
544
+ # +.rbs+ file path by searching +shared_type_paths+.
545
+ #
546
+ # @param import_path [String] Bare import name without extension.
547
+ # @return [String, nil] Absolute file path, or nil if not found.
548
+ #: (String) -> String?
549
+ def resolve_import_path(import_path)
550
+ return nil unless defined?(Rails)
551
+
552
+ McpAuthorization.config.shared_type_paths.each do |base|
553
+ candidate = Rails.root.join(base, "#{import_path}.rbs")
554
+ return candidate.to_s if File.exist?(candidate)
555
+ end
556
+ nil
557
+ end
558
+
559
+ # Parse type definitions from a standalone +.rbs+ file (shared types).
560
+ #
561
+ # Unlike +parse_type_aliases+, this parses bare RBS syntax (no +#+ comment
562
+ # markers) — the format used in +sig/shared/*.rbs+ files:
563
+ #
564
+ # type success = { status: String, data: String }
565
+ # type priority = "low"
566
+ # | "medium"
567
+ # | "high"
568
+ #
569
+ # Record types are parsed into JSON Schema objects; string literal
570
+ # unions become +{type: "string", enum: [...]}+.
571
+ #
572
+ # @param path [String] Absolute path to the +.rbs+ file.
573
+ # @return [Hash{String => Hash}] Type name → resolved JSON Schema.
574
+ #: (String) -> Hash[String, Hash[Symbol, untyped]]
575
+ def parse_rbs_file(path)
576
+ content = File.read(path)
577
+ aliases = {}
578
+ current_name = nil #: String?
579
+ current_body = +""
580
+
581
+ content.each_line do |line|
582
+ stripped = line.strip
583
+
584
+ if stripped =~ /\Atype (\w+) = \{/
585
+ current_name = $1.to_s
586
+ current_body = "{"
587
+ elsif stripped =~ /\Atype (\w+) = "([^"]+)"/
588
+ aliases[$1.to_s] = parse_rbs_string_union($2.to_s, line, content)
589
+ elsif current_name
590
+ current_body << stripped
591
+ if brace_balanced?(current_body)
592
+ aliases[current_name] = current_body
593
+ current_name = nil
594
+ current_body = +""
595
+ end
596
+ end
597
+ end
598
+
599
+ resolved = {}
600
+ aliases.each do |name, value|
601
+ resolved[name] = if value.is_a?(String)
602
+ parse_record_type(value, resolved.merge(aliases_to_schemas(aliases, resolved)))
603
+ else
604
+ value
605
+ end
606
+ end
607
+ resolved
608
+ end
609
+
610
+ # Parse a multi-line string literal union from an .rbs file:
611
+ # type priority = "low"
612
+ # | "medium"
613
+ # | "high"
614
+ #
615
+ # @return [Hash] +{type: "string", enum: ["low", "medium", "high"]}+
616
+ #: (String, String, String) -> Hash[Symbol, untyped]
617
+ def parse_rbs_string_union(first_value, line, content)
618
+ values = [first_value]
619
+ content.each_line.drop_while { |l| l != line }.drop(1).each do |next_line|
620
+ if next_line =~ /^\s*\|\s*"([^"]+)"/
621
+ values << $1.to_s
622
+ else
623
+ break
624
+ end
625
+ end
626
+ { type: "string", enum: values }
627
+ end
628
+
629
+ # Extract the raw body of a named +# @rbs type+ definition from
630
+ # handler source, preserving any +@tag(...)+ annotations for later
631
+ # filtering.
632
+ #
633
+ # Handles both record types (+{ ... }+) and union types (+a | b | c+),
634
+ # including multi-line continuation with +# |+.
635
+ #
636
+ # @param content [String] Full source file contents.
637
+ # @param type_name [String] Type name to find (e.g. +"input"+, +"output"+).
638
+ # @return [Hash, nil] +{kind: :record, body: "..."}+, +{kind: :union, body: "..."}+, or nil.
639
+ #: (String, String) -> Hash[Symbol, untyped]?
640
+ def find_raw_type_body(content, type_name)
641
+ return nil if content.empty?
642
+
643
+ lines = content.lines
644
+ pattern = Regexp.escape(type_name)
645
+
646
+ lines.each_with_index do |line, idx|
647
+ rest = lines[(idx + 1)..] || []
648
+
649
+ if line =~ /# @rbs type #{pattern} = \{/
650
+ body = "{"
651
+ rest.each do |next_line|
652
+ stripped = next_line.strip.sub(/^#\s*/, "")
653
+ body << stripped
654
+ return { kind: :record, body: body } if brace_balanced?(body)
655
+ end
656
+
657
+ elsif line =~ /# @rbs type #{pattern} = ([^{].+)/
658
+ expr = $1.to_s.strip
659
+ rest.each do |next_line|
660
+ if next_line =~ /^\s*#\s*\|\s*(.+)/
661
+ expr += " | " + $1.to_s.strip
662
+ else
663
+ break
664
+ end
665
+ end
666
+ return { kind: :union, body: expr }
667
+ end
668
+ end
669
+
670
+ nil
671
+ end
672
+
673
+ # Parse +# @rbs type+ definitions from handler source into resolved
674
+ # JSON Schema. These are the handler's local type definitions (as
675
+ # opposed to shared types loaded via +# @rbs import+).
676
+ #
677
+ # Handles record types and string literal unions:
678
+ #
679
+ # # @rbs type success = { status: String, data: String }
680
+ # # @rbs type priority = "low"
681
+ # # | "medium"
682
+ # # | "high"
683
+ #
684
+ # @param content [String] Full source file contents.
685
+ # @return [Hash{String => Hash}] Type name → resolved JSON Schema.
686
+ #: (String) -> Hash[String, Hash[Symbol, untyped]]
687
+ def parse_type_aliases(content)
688
+ return {} if content.empty?
689
+
690
+ aliases = {}
691
+ current_name = nil #: String?
692
+ current_body = +""
693
+
694
+ content.each_line do |line|
695
+ if line =~ /# @rbs type (\w+) = \{/
696
+ current_name = $1.to_s
697
+ current_body = "{"
698
+ elsif line =~ /# @rbs type (\w+) = "([^"]+)"/
699
+ aliases[$1.to_s] = parse_string_union($2.to_s, line, content)
700
+ elsif current_name
701
+ stripped = line.strip.sub(/^#\s*/, "")
702
+ current_body << stripped
703
+ if brace_balanced?(current_body)
704
+ aliases[current_name] = current_body
705
+ current_name = nil
706
+ current_body = +""
707
+ end
708
+ end
709
+ end
710
+
711
+ resolved = {}
712
+ aliases.each do |name, value|
713
+ resolved[name] = if value.is_a?(String)
714
+ parse_record_type(value, resolved.merge(aliases_to_schemas(aliases, resolved)))
715
+ else
716
+ value
717
+ end
718
+ end
719
+ resolved
720
+ end
721
+
722
+ # Parse the +#:+ method signature annotation above +def call+ into
723
+ # an array of parameter descriptors.
724
+ #
725
+ # The annotation looks like:
726
+ #
727
+ # #: (name: String @min(1), ?limit: Integer @requires(:admin)) -> Hash[Symbol, untyped]
728
+ #
729
+ # Each parameter becomes a hash with +:name+, +:type+, +:required+,
730
+ # and +:tags+ (parsed via +extract_tags+). The +?+ prefix marks a
731
+ # parameter as optional.
732
+ #
733
+ # @param content [String] Full source file contents.
734
+ # @return [Array<Hash>] Parameter descriptors.
735
+ #: (String) -> Array[Hash[Symbol, untyped]]
736
+ def parse_call_params(content)
737
+ return [] if content.empty?
738
+
739
+ lines = content.lines
740
+ call_idx = lines.index { |l| l =~ /\s*def (self\.)?call\(/ }
741
+ return [] unless call_idx
742
+
743
+ annotation = +""
744
+ i = call_idx - 1
745
+ while i >= 0 && lines[i] =~ /^\s*#:/
746
+ annotation.prepend(lines[i].sub(/^\s*#:\s*/, "").strip + " ")
747
+ i -= 1
748
+ end
749
+
750
+ params = []
751
+ if annotation =~ /\((.+)\)\s*->/m
752
+ $1.to_s.split(",").each do |param|
753
+ param = param.strip
754
+ next unless param =~ /\A(\?)?([\w]+):\s*(.+)\z/
755
+ opt, name, type = $1, $2.to_s, $3.to_s.strip
756
+ next if name == "server_context"
757
+
758
+ type, tags = extract_tags(type)
759
+
760
+ params << {
761
+ name: name,
762
+ type: type,
763
+ required: opt.nil? && !type.end_with?("?"),
764
+ tags: tags
765
+ }
766
+ end
767
+ end
768
+
769
+ params
770
+ end
771
+
772
+ # ---------------------------------------------------------------
773
+ # Type resolution helpers
774
+ # ---------------------------------------------------------------
775
+
776
+ # Convert unresolved aliases into placeholder schemas so that
777
+ # forward references work during record parsing.
778
+ #: (Hash[String, untyped], Hash[String, Hash[Symbol, untyped]]) -> Hash[String, Hash[Symbol, untyped]]
779
+ def aliases_to_schemas(aliases, already_resolved)
780
+ result = {}
781
+ aliases.each do |name, value|
782
+ next if already_resolved.key?(name)
783
+ result[name] = value.is_a?(Hash) ? value : { type: "string" }
784
+ end
785
+ result
786
+ end
787
+
788
+ # Parse a bare record type body (e.g. +"{name: String, age: Integer}"+)
789
+ # into a JSON Schema object. Used for both shared .rbs files and
790
+ # inline +# @rbs type+ definitions.
791
+ #
792
+ # @param body [String] Record body including surrounding braces.
793
+ # @param type_map [Hash] Resolved types for reference lookups.
794
+ # @return [Hash] JSON Schema object with +properties+ and +required+.
795
+ #: (String, ?Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
796
+ def parse_record_type(body, type_map = {})
797
+ properties = {}
798
+ required = []
799
+
800
+ inner = body.strip.sub(/\A\{/, "").sub(/\}\z/, "").strip
801
+
802
+ inner.scan(/(\w+):\s*([^,}]+)/) do |match|
803
+ key, type_str = match[0].to_s, match[1].to_s
804
+ type_str, tags = extract_tags(type_str.strip)
805
+ optional = key.end_with?("?")
806
+ clean_key = key.delete_suffix("?")
807
+
808
+ schema = rbs_type_to_json_schema(type_str, type_map)
809
+ properties[clean_key.to_sym] = apply_tags(schema, tags)
810
+ required << clean_key unless optional
811
+ end
812
+
813
+ schema = { type: "object", properties: properties } #: Hash[Symbol, untyped]
814
+ schema[:required] = required if required.any?
815
+ schema
816
+ end
817
+
818
+ # Convert a single RBS type expression into its JSON Schema equivalent.
819
+ #
820
+ # Handles:
821
+ # - Primitives: +String+ → +{type: "string"}+, +Integer+ → +{type: "integer"}+, etc.
822
+ # - Arrays: +Array[String]+ → +{type: "array", items: {type: "string"}}+
823
+ # - Optionals: +String?+ → +{type: "string"}+ (nullability is handled at the field level)
824
+ # - Inline records: +{name: String}+ → nested object schema
825
+ # - Unions: +"a" | "b"+ → string enum; +A | B+ → +oneOf+
826
+ # - Named types: looked up in +type_map+, falling back to +{type: "string"}+
827
+ #
828
+ # @param rbs_type [String] RBS type expression.
829
+ # @param type_map [Hash] Resolved type definitions for named type lookups.
830
+ # @return [Hash] JSON Schema hash.
831
+ #: (String, ?Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
832
+ def rbs_type_to_json_schema(rbs_type, type_map = {})
833
+ stripped = rbs_type.strip
834
+ case stripped
835
+ when "String"
836
+ { type: "string" }
837
+ when "Integer"
838
+ { type: "integer" }
839
+ when "Float"
840
+ { type: "number" }
841
+ when "bool", "TrueClass | FalseClass"
842
+ { type: "boolean" }
843
+ when "true"
844
+ { type: "boolean", const: true }
845
+ when "false"
846
+ { type: "boolean", const: false }
847
+ when /\AArray\[(.+)\]\z/
848
+ { type: "array", items: rbs_type_to_json_schema($1.to_s, type_map) }
849
+ when /\A(\w+)\?\z/
850
+ rbs_type_to_json_schema($1.to_s, type_map)
851
+ when /\A\{/
852
+ parse_record_type(stripped, type_map)
853
+ when /\|/
854
+ parts = stripped.split("|").map(&:strip)
855
+ if parts.all? { |p| p.start_with?('"') && p.end_with?('"') }
856
+ { type: "string", enum: parts.map { |p| p.delete('"') } }
857
+ else
858
+ { oneOf: parts.map { |p| rbs_type_to_json_schema(p, type_map) } }
859
+ end
860
+ else
861
+ type_map[stripped] || { type: "string" }
862
+ end
863
+ end
864
+
865
+ # Look up a named type in the type map. Returns a bare +{type: "object"}+
866
+ # if the name is not found (defensive fallback).
867
+ #: (String, Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
868
+ def resolve_type(name, type_map)
869
+ type_map[name] || { type: "object" }
870
+ end
871
+
872
+ # Wrap a partial schema (with +properties+, +required+, etc.) in a
873
+ # top-level +{type: "object", ...}+ envelope.
874
+ #: (Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
875
+ def build_input_schema(types)
876
+ { type: "object" }.merge(types)
877
+ end
878
+
879
+ # Locate the source file for a handler class by inspecting
880
+ # +Method#source_location+ on its +#call+ method. This is how the
881
+ # compiler finds the RBS annotations to parse.
882
+ #
883
+ # @param handler_class [Class]
884
+ # @return [String, nil] Absolute file path, or nil.
885
+ #: (untyped) -> String?
886
+ def find_source_file(handler_class)
887
+ if handler_class.method_defined?(:call)
888
+ handler_class.instance_method(:call).source_location&.first
889
+ elsif handler_class.respond_to?(:call)
890
+ handler_class.method(:call).source_location&.first
891
+ end
892
+ end
893
+
894
+ # Check whether a string has balanced curly braces. Used to detect
895
+ # the end of multi-line record type definitions.
896
+ #: (String) -> bool
897
+ def brace_balanced?(str)
898
+ str.count("{") == str.count("}")
899
+ end
900
+
901
+ # Parse a multi-line string literal union from handler source comments:
902
+ # # @rbs type priority = "low"
903
+ # # | "medium"
904
+ # # | "high"
905
+ #
906
+ # @return [Hash] +{type: "string", enum: ["low", "medium", "high"]}+
907
+ #: (String, String, String) -> Hash[Symbol, untyped]
908
+ def parse_string_union(first_value, line, content)
909
+ values = [first_value]
910
+ content.each_line.drop_while { |l| l != line }.drop(1).each do |next_line|
911
+ if next_line =~ /^\s*#\s*\|\s*"([^"]+)"/
912
+ values << $1.to_s
913
+ else
914
+ break
915
+ end
916
+ end
917
+ { type: "string", enum: values }
918
+ end
919
+
920
+ # ---------------------------------------------------------------
921
+ # $ref / $defs optimization
922
+ #
923
+ # When a named type (e.g. "address") appears in multiple places in
924
+ # the compiled schema, inlining it everywhere wastes tokens. This
925
+ # pass detects multi-use types, hoists them into a top-level $defs
926
+ # block, and replaces inline occurrences with $ref pointers:
927
+ #
928
+ # { "$ref": "#/$defs/address" }
929
+ #
930
+ # Single-use types are left inlined — the $ref overhead isn't worth
931
+ # it for types that only appear once.
932
+ # ---------------------------------------------------------------
933
+
934
+ # Wrap a compiled schema with +$defs+ for named types that appear
935
+ # more than once. Returns the schema unchanged if no deduplication
936
+ # is worthwhile.
937
+ #
938
+ # @param schema [Hash] Compiled JSON Schema.
939
+ # @param type_map [Hash] Named type definitions.
940
+ # @return [Hash] Schema, possibly with +$defs+ added.
941
+ #: (Hash[Symbol, untyped], Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
942
+ def with_ref_injection(schema, type_map)
943
+ return schema unless schema.is_a?(Hash)
944
+
945
+ # Build lookup of named types that are non-trivial schemas (worth deduplicating)
946
+ type_schemas = {}
947
+ type_map.each do |name, type_schema|
948
+ next unless type_schema.is_a?(Hash) && type_schema.size > 1
949
+ type_schemas[name] = type_schema
950
+ end
951
+ return schema if type_schemas.empty?
952
+
953
+ usage = Hash.new(0)
954
+ count_usages(schema, type_schemas, usage)
955
+
956
+ multi = usage.select { |_, c| c > 1 }
957
+ return schema if multi.empty?
958
+
959
+ defs = {}
960
+ multi.each_key { |name| defs[name] = type_schemas[name] }
961
+
962
+ replaced = deep_replace(schema, multi, type_schemas)
963
+ replaced[:"$defs"] = defs
964
+ replaced
965
+ end
966
+
967
+ # Walk the schema tree and count how many times each named type's
968
+ # schema appears as a value. Only types with count > 1 are worth
969
+ # extracting into +$defs+.
970
+ #: (Hash[Symbol, untyped], Hash[String, Hash[Symbol, untyped]], Hash[String, Integer]) -> void
971
+ def count_usages(schema, type_schemas, usage)
972
+ return unless schema.is_a?(Hash)
973
+
974
+ type_schemas.each do |name, ts|
975
+ usage[name] += 1 if schema == ts
976
+ end
977
+
978
+ schema[:properties]&.each_value { |v| count_usages(v, type_schemas, usage) }
979
+ count_usages(schema[:items], type_schemas, usage) if schema[:items].is_a?(Hash)
980
+ [:oneOf, :anyOf, :allOf].each do |k|
981
+ schema[k]&.each { |s| count_usages(s, type_schemas, usage) }
982
+ end
983
+ end
984
+
985
+ # Recursively replace occurrences of multi-use named type schemas
986
+ # with +{"$ref": "#/$defs/<name>"}+ pointers. Walks +properties+,
987
+ # +items+, +oneOf+, +anyOf+, and +allOf+.
988
+ #: (Hash[Symbol, untyped], Hash[String, Integer], Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
989
+ def deep_replace(schema, targets, type_schemas)
990
+ return schema unless schema.is_a?(Hash)
991
+
992
+ type_schemas.each do |name, ts|
993
+ if targets.key?(name) && schema == ts
994
+ return { "$ref": "#/$defs/#{name}" }
995
+ end
996
+ end
997
+
998
+ result = {}
999
+ schema.each do |key, value|
1000
+ result[key] = case key
1001
+ when :properties
1002
+ value.transform_values { |v| deep_replace(v, targets, type_schemas) }
1003
+ when :items
1004
+ deep_replace(value, targets, type_schemas)
1005
+ when :oneOf, :anyOf, :allOf
1006
+ value.map { |s| deep_replace(s, targets, type_schemas) }
1007
+ else
1008
+ value
1009
+ end
1010
+ end
1011
+ result
1012
+ end
1013
+ end
1014
+ end
1015
+ end