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,245 @@
1
+ module McpAuthorization
2
+ # Base class for MCP tools with schema-shaping authorization.
3
+ #
4
+ # Subclass this instead of MCP::Tool directly. Each subclass is a thin
5
+ # declarative wrapper — the actual business logic lives in a *handler
6
+ # class* (a plain Ruby class that includes DSL) pointed to by
7
+ # +dynamic_contract+.
8
+ #
9
+ # == Defining a tool
10
+ #
11
+ # class Tools::ListOrders < McpAuthorization::Tool
12
+ # tool_name "list_orders"
13
+ # authorization :view_orders
14
+ # tags "operator", "fulfillment"
15
+ # read_only!
16
+ #
17
+ # dynamic_contract Handlers::ListOrders
18
+ # end
19
+ #
20
+ class Tool < MCP::Tool
21
+ class NotAuthorizedError < StandardError; end
22
+
23
+ class << self
24
+ #: Symbol?
25
+ attr_reader :_permission
26
+
27
+ #: Array[String]?
28
+ attr_reader :_tags
29
+
30
+ #: untyped
31
+ attr_reader :_contract_handler
32
+
33
+ #: (Class) -> void
34
+ def inherited(subclass)
35
+ super
36
+ McpAuthorization::ToolRegistry.register(subclass)
37
+ end
38
+
39
+ # Declare the permission flag required to see this tool.
40
+ #: (Symbol) -> void
41
+ def authorization(permission)
42
+ @_permission = permission
43
+ end
44
+
45
+ # Declare which MCP domains this tool belongs to.
46
+ #: (*String | Array[String]) -> void
47
+ def tags(*list)
48
+ @_tags = list.flatten
49
+ end
50
+
51
+ # MCP annotation hint shorthands
52
+ #: () -> void
53
+ def read_only!; merge_annotations(read_only_hint: true) end
54
+ #: () -> void
55
+ def destructive!; merge_annotations(destructive_hint: true) end
56
+ #: () -> void
57
+ def not_destructive!; merge_annotations(destructive_hint: false) end
58
+ #: () -> void
59
+ def idempotent!; merge_annotations(idempotent_hint: true) end
60
+ #: () -> void
61
+ def open_world!; merge_annotations(open_world_hint: true) end
62
+ #: () -> void
63
+ def closed_world!; merge_annotations(open_world_hint: false) end
64
+
65
+ # Point this tool at its handler class.
66
+ #: (untyped) -> void
67
+ def dynamic_contract(handler_class)
68
+ @_contract_handler = handler_class
69
+ @_contract_validated = false
70
+ end
71
+
72
+ # Build the tool description for this user.
73
+ #: (server_context: untyped) -> String
74
+ def dynamic_description(server_context:)
75
+ handler_instance(server_context).description
76
+ end
77
+
78
+ # Compile the input JSON Schema for this user.
79
+ #: (server_context: untyped) -> Hash[Symbol, untyped]
80
+ def dynamic_input_schema(server_context:)
81
+ McpAuthorization::RbsSchemaCompiler.compile_input(
82
+ _contract_handler,
83
+ server_context: server_context
84
+ )
85
+ end
86
+
87
+ # Compile the output JSON Schema for this user.
88
+ #: (server_context: untyped) -> Hash[Symbol, untyped]?
89
+ def dynamic_output_schema(server_context:)
90
+ McpAuthorization::RbsSchemaCompiler.compile_output(
91
+ _contract_handler,
92
+ server_context: server_context
93
+ )
94
+ end
95
+
96
+ # Check whether the current user is allowed to see this tool.
97
+ #: (untyped) -> bool
98
+ def permitted?(server_context)
99
+ return true if _permission.nil?
100
+ server_context.current_user.can?(_permission)
101
+ end
102
+
103
+ # Build the full MCP tool definition hash for +tools/list+.
104
+ # Returns nil if the user is not permitted.
105
+ #: (server_context: untyped) -> Hash[Symbol, untyped]?
106
+ def to_mcp_definition(server_context:)
107
+ return nil unless permitted?(server_context)
108
+ validate_contract!(_contract_handler) unless @_contract_validated
109
+ @_contract_validated = true
110
+
111
+ {
112
+ name: tool_name,
113
+ description: dynamic_description(server_context: server_context),
114
+ inputSchema: dynamic_input_schema(server_context: server_context),
115
+ outputSchema: dynamic_output_schema(server_context: server_context),
116
+ annotations: @_annotations_hash || {}
117
+ }
118
+ end
119
+
120
+ # Execute the tool by delegating to the handler.
121
+ #: (?server_context: untyped?, **untyped) -> untyped
122
+ def call(server_context: nil, **params)
123
+ raise NotAuthorizedError unless server_context && permitted?(server_context)
124
+ handler_instance(server_context).call(**params)
125
+ end
126
+
127
+ # Create an anonymous MCP::Tool subclass with this user's schemas baked in.
128
+ #: (untyped) -> Class?
129
+ def materialize_for(server_context)
130
+ defn = to_mcp_definition(server_context: server_context)
131
+ return nil unless defn
132
+
133
+ handler = _contract_handler
134
+ ctx = server_context
135
+
136
+ Class.new(MCP::Tool) do
137
+ tool_name defn[:name]
138
+ description defn[:description]
139
+ input_schema defn[:inputSchema]
140
+ output_schema defn[:outputSchema] if defn[:outputSchema]
141
+ annotations(**defn[:annotations]) if defn[:annotations]&.any?
142
+
143
+ define_singleton_method(:call) do |server_context: nil, **params|
144
+ effective_ctx = server_context || ctx
145
+ result = handler.new(server_context: effective_ctx).call(**params)
146
+ MCP::Tool::Response.new([ { type: "text", text: result.to_json } ])
147
+ end
148
+ end
149
+ end
150
+
151
+ private
152
+
153
+ #: (**untyped) -> void
154
+ def merge_annotations(**new_hints)
155
+ hints = (@_annotation_hints || {}).merge(new_hints)
156
+ @_annotation_hints = hints
157
+ annotations(**hints)
158
+ end
159
+
160
+ #: (untyped) -> untyped
161
+ def handler_instance(server_context)
162
+ _contract_handler.new(server_context: server_context)
163
+ end
164
+
165
+ #: (untyped) -> void
166
+ def validate_contract!(handler_class)
167
+ errors = []
168
+
169
+ unless handler_class.method_defined?(:call)
170
+ errors << "missing instance method #call"
171
+ end
172
+ unless handler_class.method_defined?(:description)
173
+ errors << "missing instance method #description"
174
+ end
175
+
176
+ init = handler_class.instance_method(:initialize) rescue nil
177
+ unless init&.parameters&.any? { |type, name| name == :server_context && type == :keyreq }
178
+ errors << "missing initialize(server_context:)"
179
+ end
180
+
181
+ source_file = McpAuthorization::RbsSchemaCompiler.send(:find_source_file, handler_class)
182
+ if source_file && File.exist?(source_file)
183
+ content = File.read(source_file)
184
+ has_input = content.include?("# @rbs type input =")
185
+ has_call_annotation = content.match?(/^\s*#:.*->/m)
186
+ has_output = content.include?("# @rbs type output =")
187
+
188
+ unless has_input || has_call_annotation
189
+ errors << "missing input schema (define #: annotation above def call, or # @rbs type input = { ... })"
190
+ end
191
+
192
+ unless has_output
193
+ errors << "missing output schema (define # @rbs type output = variant1 | variant2 | ...)"
194
+ end
195
+
196
+ if has_output
197
+ begin
198
+ cached = McpAuthorization::RbsSchemaCompiler.send(:cache_for, handler_class)
199
+ if cached[:raw_output]&.dig(:kind) == :union
200
+ primitives = %w[String Integer Float bool true false]
201
+ parts = cached[:raw_output][:body].split("|").map(&:strip).reject(&:empty?)
202
+ parts.each do |part|
203
+ name = part.gsub(/\s*@\w+\([^)]*\)/, "").strip
204
+ next if primitives.include?(name)
205
+ next if cached[:type_map].key?(name)
206
+ errors << "output variant '#{name}' does not resolve to a defined type (check @rbs type definitions and @rbs import statements)"
207
+ end
208
+ end
209
+ rescue => e
210
+ # Don't fail validation if cache isn't ready
211
+ end
212
+ end
213
+ elsif source_file.nil?
214
+ errors << "could not locate source file (is #call defined?)"
215
+ end
216
+
217
+ return if errors.empty?
218
+
219
+ raise ArgumentError, <<~MSG
220
+ #{handler_class} does not satisfy the McpAuthorization handler contract.
221
+
222
+ Problems:
223
+ #{errors.map { |e| "- #{e}" }.join("\n ")}
224
+
225
+ A handler class should look like:
226
+
227
+ class MyHandler
228
+ include McpAuthorization::DSL
229
+
230
+ # @rbs type output = success | error
231
+
232
+ def description
233
+ "What this tool does"
234
+ end
235
+
236
+ #: (name: String, ?force: bool @requires(:admin)) -> Hash[Symbol, untyped]
237
+ def call(name:, force: false)
238
+ # ...
239
+ end
240
+ end
241
+ MSG
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,89 @@
1
+ module McpAuthorization
2
+ # Global registry of all McpAuthorization::Tool subclasses.
3
+ #
4
+ # Tools self-register via the +inherited+ hook in Tool, so there is no
5
+ # manual registration step — defining a class that inherits from Tool is
6
+ # enough.
7
+ #
8
+ # The registry is the entry point for two main operations:
9
+ #
10
+ # * *Listing* — +list_tools+ returns JSON-serialisable tool definitions
11
+ # filtered by domain and the current user's permissions.
12
+ #
13
+ # * *Materializing* — +tool_classes_for+ returns concrete MCP::Tool
14
+ # subclasses with per-user schemas baked in, ready to be handed to an
15
+ # MCP::Server for request handling.
16
+ #
17
+ class ToolRegistry
18
+ class << self
19
+ # Register a tool class. Called automatically by Tool.inherited.
20
+ #: (Class) -> void
21
+ def register(tool_class)
22
+ tools = (@registered_tools ||= [])
23
+ tools << tool_class unless tools.include?(tool_class)
24
+ end
25
+
26
+ # All registered tool classes. Triggers eager loading on first access.
27
+ #: () -> Array[singleton(McpAuthorization::Tool)]
28
+ def registered_tools
29
+ tools = (@registered_tools ||= [])
30
+ ensure_tools_loaded! if tools.empty?
31
+ tools
32
+ end
33
+
34
+ # Force-loads tool directories so tool classes self-register.
35
+ #: () -> void
36
+ def ensure_tools_loaded!
37
+ return if @registered_tools&.any?
38
+ return unless defined?(Rails)
39
+
40
+ McpAuthorization.config.tool_paths.each do |path|
41
+ full_path = Rails.root.join(path)
42
+ Rails.autoloaders.main.eager_load_dir(full_path) if File.directory?(full_path)
43
+ end
44
+ end
45
+
46
+ # Groups registered tools by their domain tags.
47
+ #: () -> Hash[String, Array[singleton(McpAuthorization::Tool)]]
48
+ def tools_by_domain
49
+ initial = Hash.new { |h, k| h[k] = [] } #: Hash[String, Array[singleton(McpAuthorization::Tool)]]
50
+ registered_tools.each_with_object(initial) do |tool_class, map|
51
+ (tool_class._tags || ["default"]).each do |tag|
52
+ map[tag] << tool_class
53
+ end
54
+ end
55
+ end
56
+
57
+ # Tool definitions for +tools/list+, filtered by domain and permissions.
58
+ #: (domain: String, server_context: untyped) -> Array[Hash[Symbol, untyped]]
59
+ def list_tools(domain:, server_context:)
60
+ candidates = tools_by_domain[domain] || []
61
+ candidates.filter_map do |tool_class|
62
+ tool_class.to_mcp_definition(server_context: server_context)
63
+ end
64
+ end
65
+
66
+ # Concrete MCP::Tool subclasses with per-user schemas baked in.
67
+ #: (domain: String, server_context: untyped) -> Array[singleton(MCP::Tool)]
68
+ def tool_classes_for(domain:, server_context:)
69
+ candidates = tools_by_domain[domain] || []
70
+ candidates.filter_map do |tool_class|
71
+ next unless tool_class.permitted?(server_context)
72
+ tool_class.materialize_for(server_context)
73
+ end
74
+ end
75
+
76
+ # Look up a tool by its MCP tool name across all domains.
77
+ #: (String) -> singleton(McpAuthorization::Tool)?
78
+ def find_tool(name)
79
+ registered_tools.find { |t| t.tool_name == name }
80
+ end
81
+
82
+ # Clear the registry. Called by the Engine's reloader on code change.
83
+ #: () -> void
84
+ def reset!
85
+ @registered_tools = []
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,3 @@
1
+ module McpAuthorization
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,57 @@
1
+ require "mcp"
2
+ require_relative "mcp_authorization/version"
3
+ require_relative "mcp_authorization/configuration"
4
+ require_relative "mcp_authorization/dsl"
5
+ require_relative "mcp_authorization/rbs_schema_compiler"
6
+ require_relative "mcp_authorization/tool_registry"
7
+ require_relative "mcp_authorization/tool"
8
+ require_relative "mcp_authorization/engine" if defined?(Rails)
9
+
10
+ # MCP Authorization — schema-shaping authorization for MCP tool servers.
11
+ #
12
+ # Instead of rejecting unauthorized requests after the fact, this gem shapes
13
+ # the JSON Schema that each user sees so that fields and output variants they
14
+ # are not permitted to use never appear in the schema at all. The LLM (or
15
+ # any MCP client) therefore never knows those options exist.
16
+ #
17
+ # == Quick start (Rails)
18
+ #
19
+ # # config/initializers/mcp_authorization.rb
20
+ # McpAuthorization.configure do |c|
21
+ # c.server_name = "my-app"
22
+ # c.server_version = "1.0.0"
23
+ # c.context_builder = ->(request) {
24
+ # ServerContext.new(current_user: current_user_from(request))
25
+ # }
26
+ # end
27
+ #
28
+ # == How it works
29
+ #
30
+ # 1. Tool classes inherit from McpAuthorization::Tool and declare a handler
31
+ # class via +dynamic_contract+.
32
+ # 2. Handler classes use +@rbs type+ comments and +#:+ annotations to define
33
+ # input/output schemas. Fields can be tagged with +@requires(:flag)+ to
34
+ # gate them on user permissions.
35
+ # 3. On each request the RbsSchemaCompiler compiles a per-user JSON Schema
36
+ # by filtering out fields whose +@requires+ flag the user lacks.
37
+ # 4. A fresh set of MCP::Tool subclasses is materialized with the filtered
38
+ # schemas baked in, and handed to a stateless MCP::Server.
39
+ #
40
+ # See CLAUDE.md for the full architecture walkthrough.
41
+ module McpAuthorization
42
+ class << self
43
+ # Yields the global Configuration instance for block-style setup.
44
+ #: () { (Configuration) -> void } -> void
45
+ def configure
46
+ yield configuration
47
+ end
48
+
49
+ # Returns the global Configuration instance, creating it with defaults
50
+ # on first access.
51
+ #: () -> Configuration
52
+ def configuration
53
+ @configuration ||= Configuration.new
54
+ end
55
+ alias_method :config, :configuration
56
+ end
57
+ end
@@ -0,0 +1,99 @@
1
+ namespace :mcp do
2
+ desc "Start Rails server and MCP Inspector for a given domain and role"
3
+ task :inspect, [ :domain, :role ] => :environment do |_t, args|
4
+ domain = args[:domain] || McpAuthorization.config.default_domain
5
+ role = args[:role] || "operator"
6
+ port = ENV.fetch("PORT", "3000")
7
+ mount = McpAuthorization.config.mount_path || "/mcp"
8
+ url = "http://localhost:#{port}#{mount}/#{domain}"
9
+
10
+ puts "Starting MCP Inspector"
11
+ puts " Domain: #{domain}"
12
+ puts " Role: #{role}"
13
+ puts " URL: #{url}"
14
+ puts ""
15
+
16
+ config = {
17
+ mcpServers: {
18
+ McpAuthorization.config.server_name => {
19
+ url: url,
20
+ headers: { "Authorization" => role }
21
+ }
22
+ }
23
+ }
24
+ config_path = Rails.root.join("tmp/mcp_inspector.json")
25
+ File.write(config_path, JSON.pretty_generate(config))
26
+
27
+ rails_pid = spawn(
28
+ "bundle exec rails server -p #{port}",
29
+ out: File::NULL, err: File::NULL
30
+ )
31
+
32
+ sleep 3
33
+
34
+ begin
35
+ exec(
36
+ "npx", "@modelcontextprotocol/inspector",
37
+ "--config", config_path.to_s,
38
+ "--server", McpAuthorization.config.server_name
39
+ )
40
+ ensure
41
+ Process.kill("TERM", rails_pid) rescue nil
42
+ end
43
+ end
44
+
45
+ desc "Print Claude Code MCP config for a given domain and role"
46
+ task :claude, [ :domain, :role ] => :environment do |_t, args|
47
+ domain = args[:domain] || McpAuthorization.config.default_domain
48
+ role = args[:role] || "operator"
49
+ port = ENV.fetch("PORT", "3000")
50
+ mount = McpAuthorization.config.mount_path || "/mcp"
51
+ url = "http://localhost:#{port}#{mount}/#{domain}"
52
+
53
+ config = {
54
+ "mcpServers" => {
55
+ "#{McpAuthorization.config.server_name}-#{domain}" => {
56
+ "url" => url,
57
+ "headers" => { "Authorization" => role }
58
+ }
59
+ }
60
+ }
61
+
62
+ puts "Add this to your Claude Code settings (~/.claude/settings.json):"
63
+ puts ""
64
+ puts JSON.pretty_generate(config)
65
+ puts ""
66
+ puts "Make sure Rails is running: bundle exec rails server -p #{port}"
67
+ end
68
+
69
+ desc "List available tools for a domain and role"
70
+ task :tools, [ :domain, :role ] => :environment do |_t, args|
71
+ require "json"
72
+ domain = args[:domain] || McpAuthorization.config.default_domain
73
+ role = args[:role] || "operator"
74
+
75
+ builder = McpAuthorization.config.cli_context_builder
76
+ unless builder
77
+ puts "McpAuthorization.config.cli_context_builder is not configured."
78
+ puts "Set it in config/initializers/mcp_authorization.rb"
79
+ exit 1
80
+ end
81
+
82
+ ctx = builder.call(domain: domain, role: role)
83
+
84
+ tools = McpAuthorization::ToolRegistry.list_tools(domain: domain, server_context: ctx)
85
+
86
+ if tools.empty?
87
+ puts "No tools available for domain '#{domain}' with role '#{role}'"
88
+ else
89
+ tools.each do |tool|
90
+ puts "#{tool[:name]}"
91
+ puts " #{tool[:description]}"
92
+ puts " input: #{tool[:inputSchema][:properties].keys.join(', ')}"
93
+ output_shapes = tool.dig(:outputSchema, :oneOf)&.map { |s| s[:properties]&.keys&.join(', ') }
94
+ puts " output: #{output_shapes&.join(' | ')}" if output_shapes
95
+ puts ""
96
+ end
97
+ end
98
+ end
99
+ end
metadata ADDED
@@ -0,0 +1,112 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mcp_authorization
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Andy
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-04-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '6.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '6.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: mcp
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.10'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.10'
41
+ - !ruby/object:Gem::Dependency
42
+ name: minitest
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '5.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '5.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '13.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '13.0'
69
+ description: Add MCP tool serving to any Rails app. Write @rbs type annotations with
70
+ @requires(:flag) tags and the gem compiles per-user JSON Schema automatically. Feature
71
+ flags, permissions, and plan tiers all work through a single can?(:symbol) predicate.
72
+ email:
73
+ executables: []
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - LICENSE
78
+ - README.md
79
+ - app/controllers/mcp_authorization/mcp_controller.rb
80
+ - lib/mcp_authorization.rb
81
+ - lib/mcp_authorization/configuration.rb
82
+ - lib/mcp_authorization/dsl.rb
83
+ - lib/mcp_authorization/engine.rb
84
+ - lib/mcp_authorization/rbs_schema_compiler.rb
85
+ - lib/mcp_authorization/tool.rb
86
+ - lib/mcp_authorization/tool_registry.rb
87
+ - lib/mcp_authorization/version.rb
88
+ - lib/tasks/mcp_authorization.rake
89
+ homepage:
90
+ licenses:
91
+ - MIT
92
+ metadata: {}
93
+ post_install_message:
94
+ rdoc_options: []
95
+ require_paths:
96
+ - lib
97
+ required_ruby_version: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '3.1'
102
+ required_rubygems_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '0'
107
+ requirements: []
108
+ rubygems_version: 3.4.10
109
+ signing_key:
110
+ specification_version: 4
111
+ summary: Rails engine for MCP tools with per-request schema discrimination
112
+ test_files: []