mcpable 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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcpable
4
+ module Ports
5
+ class SchemaStrategy
6
+ def input_schema(definition)
7
+ raise NotImplementedError, "#{self.class}#input_schema"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcpable
4
+ module Ports
5
+ class Source
6
+ Page = Data.define(:records, :total, :page, :per_page)
7
+
8
+ attr_reader :base
9
+
10
+ def initialize(base)
11
+ @base = base
12
+ end
13
+
14
+ def fetch(filters:, scope:, page:, per_page:, order:)
15
+ raise NotImplementedError, "#{self.class}#fetch"
16
+ end
17
+
18
+ def find(id, scope:)
19
+ raise NotImplementedError, "#{self.class}#find"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcpable
4
+ module Ports
5
+ class Transport
6
+ attr_reader :registry, :runtime, :profile
7
+
8
+ def initialize(registry:, runtime:, profile: :default)
9
+ @registry = registry
10
+ @runtime = runtime
11
+ @profile = profile
12
+ end
13
+
14
+ def list_tools
15
+ raise NotImplementedError, "#{self.class}#list_tools"
16
+ end
17
+
18
+ def handle(raw_request, context: {})
19
+ raise NotImplementedError, "#{self.class}#handle"
20
+ end
21
+
22
+ def serve_stdio(context: {})
23
+ raise NotImplementedError, "#{self.class}#serve_stdio"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcpable
4
+ module Pundit
5
+ class Authorize < Ports::Middleware
6
+ SCOPED_ACTIONS = %i[list show].freeze
7
+
8
+ def call(ctx)
9
+ policy_class = ctx.definition.metadata[:policy] or return @app.call(ctx)
10
+ record = ctx.definition.metadata[:model]
11
+ action = ctx.definition.metadata[:action]
12
+ policy = policy_class.new(ctx.user, record)
13
+ return Result.deny("not authorized") unless policy.public_send("#{action}?")
14
+
15
+ ctx.assigns[:scope] = resolve_scope(policy_class, ctx.user, record) if SCOPED_ACTIONS.include?(action)
16
+
17
+ @app.call(ctx)
18
+ end
19
+
20
+ private
21
+
22
+ def resolve_scope(policy_class, user, record)
23
+ scope_class = policy_class.const_defined?(:Scope, false) ? policy_class.const_get(:Scope, false) : nil
24
+ raise MissingScopeError, policy_class.name unless scope_class
25
+
26
+ base = record.respond_to?(:all) ? record.all : record
27
+ scope_class.new(user, base).resolve
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcpable"
4
+ require "mcpable/pundit/authorize"
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+ require "mcpable/rails/loader"
5
+
6
+ module Mcpable
7
+ module Rails
8
+ class Engine < ::Rails::Engine
9
+ isolate_namespace Mcpable::Rails
10
+
11
+ config.mcpable = ActiveSupport::OrderedOptions.new
12
+ config.mcpable.eager_load_paths = %w[app/models app/tools]
13
+
14
+ initializer "mcpable.routes" do |app|
15
+ app.routes.append do
16
+ post "/mcp", to: "mcpable/rails/tools#create", as: :mcpable_root
17
+ end
18
+ end
19
+
20
+ config.to_prepare do
21
+ if Mcpable::Rails::Loader.schema_ready?
22
+ Mcpable::Rails::Loader.reload!
23
+ else
24
+ ::Rails.logger&.warn("[mcpable] database schema is not ready; no MCP tools were registered")
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcpable
4
+ module Rails
5
+ module Loader
6
+ module_function
7
+
8
+ def reload!(root: ::Rails.root, relative_paths: nil, autoloaders: ::Rails.autoloaders)
9
+ Mcpable.registry.reset!
10
+ resolve_paths(root, relative_paths).each { |path| load_path(path, autoloaders) }
11
+ Mcpable.registry
12
+ end
13
+
14
+ def schema_ready?
15
+ return true unless defined?(::ActiveRecord::Base)
16
+
17
+ table = ::ActiveRecord::Base.schema_migrations_table_name
18
+ ::ActiveRecord::Base.connection_pool.with_connection do |connection|
19
+ connection.data_source_exists?(table)
20
+ end
21
+ rescue StandardError
22
+ false
23
+ end
24
+
25
+ def resolve_paths(root, relative_paths)
26
+ paths = relative_paths || Engine.config.mcpable.eager_load_paths
27
+ paths.map { |relative| root.join(relative) }.select(&:exist?)
28
+ end
29
+
30
+ def load_path(path, autoloaders)
31
+ loader = autoloader_for(path, autoloaders)
32
+ return loader.eager_load_dir(path.to_s) if loader
33
+
34
+ Dir.glob(File.join(path.to_s, "**", "*.rb")).sort.each { |file| require file }
35
+ end
36
+
37
+ def autoloader_for(path, autoloaders)
38
+ return nil if autoloaders.nil?
39
+
40
+ autoloaders.find do |candidate|
41
+ candidate.respond_to?(:eager_load_dir) && managed?(candidate, path.to_s)
42
+ end
43
+ end
44
+
45
+ def managed?(loader, path)
46
+ roots(loader).any? { |root| path == root || path.start_with?("#{root}/") }
47
+ end
48
+
49
+ def roots(loader)
50
+ return [] unless loader.respond_to?(:dirs)
51
+
52
+ dirs = loader.dirs
53
+ dirs.respond_to?(:keys) ? dirs.keys : Array(dirs)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcpable"
4
+ require "mcpable/transports/official_mcp"
5
+ require "mcpable/rails/engine"
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcpable
4
+ class Registry
5
+ def initialize
6
+ @definitions = {}
7
+ end
8
+
9
+ def register(definition)
10
+ name = definition.name.to_s
11
+ raise DuplicateToolError, name if @definitions.key?(name)
12
+
13
+ @definitions[name] = definition
14
+ end
15
+
16
+ def fetch(name)
17
+ @definitions.fetch(name.to_s) { raise UnknownToolError, name.to_s }
18
+ end
19
+
20
+ def key?(name) = @definitions.key?(name.to_s)
21
+
22
+ def for_profile(profile)
23
+ @definitions.values.select { |d| d.profiles.include?(profile) }
24
+ end
25
+
26
+ def all = @definitions.values
27
+
28
+ def names = @definitions.keys
29
+
30
+ def reset!
31
+ @definitions = {}
32
+ self
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcpable
4
+ module Resource
5
+ def self.included(base)
6
+ base.extend(ClassMethods)
7
+ end
8
+
9
+ module ClassMethods
10
+ attr_reader :mcp_definitions
11
+
12
+ def mcpable(&block)
13
+ builder = Dsl::ResourceBuilder.new(self)
14
+ Dsl.evaluate(builder, &block)
15
+ @mcp_definitions = builder.compile
16
+ @mcp_definitions.each { |definition| Mcpable.registry.register(definition) }
17
+ @mcp_definitions
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcpable
4
+ Result = Data.define(:status, :payload, :error) do
5
+ def self.ok(payload) = new(status: :ok, payload: payload, error: nil)
6
+
7
+ def self.deny(msg) = new(status: :denied, payload: nil, error: msg)
8
+
9
+ def self.fail(msg) = new(status: :error, payload: nil, error: msg)
10
+
11
+ def ok? = status == :ok
12
+
13
+ def denied? = status == :denied
14
+
15
+ def error? = status == :error
16
+ end
17
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+
6
+ module Mcpable
7
+ class Runtime
8
+ PAGINATION_TYPES = { page: :integer, per_page: :integer, order: :string }.freeze
9
+
10
+ class CoercionError < StandardError; end
11
+
12
+ attr_reader :registry, :config
13
+
14
+ def initialize(registry: nil, config: nil)
15
+ @registry = registry
16
+ @config = config
17
+ end
18
+
19
+ def call_tool(name, args: {}, context: {})
20
+ definition = current_registry.fetch(name)
21
+ symbolized = symbolize(args)
22
+
23
+ known = allowed_types(definition)
24
+ unknown = symbolized.keys - known.keys
25
+ return Result.fail("unknown arguments: #{unknown.join(', ')}") if unknown.any?
26
+
27
+ missing = definition.arguments.select(&:required).map(&:name) - symbolized.keys
28
+ return Result.fail("missing required arguments: #{missing.join(', ')}") if missing.any?
29
+
30
+ coerced =
31
+ begin
32
+ coerce_all(symbolized, known)
33
+ rescue CoercionError => e
34
+ return Result.fail(e.message)
35
+ end
36
+
37
+ ctx = ToolCall.new(definition: definition, args: coerced, context: context)
38
+ current_config.pipeline.call(ctx)
39
+ end
40
+
41
+ private
42
+
43
+ def current_registry = @registry || Mcpable.registry
44
+
45
+ def current_config = @config || Mcpable.config
46
+
47
+ def symbolize(args)
48
+ (args || {}).each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
49
+ end
50
+
51
+ def allowed_types(definition)
52
+ types = definition.arguments.to_h { |a| [a.name, a.type] }
53
+ types = PAGINATION_TYPES.merge(types) if definition.metadata[:paginated]
54
+ types
55
+ end
56
+
57
+ def coerce_all(args, types)
58
+ args.each_with_object({}) do |(key, value), out|
59
+ out[key] = coerce(key, value, types[key])
60
+ end
61
+ end
62
+
63
+ def coerce(key, value, type)
64
+ return nil if value.nil?
65
+
66
+ case type
67
+ when :integer then coerce_integer(key, value)
68
+ when :number then coerce_number(key, value)
69
+ when :boolean then coerce_boolean(key, value)
70
+ when :date then coerce_date(key, value)
71
+ when :datetime then coerce_datetime(key, value)
72
+ when :string then value.to_s
73
+ else value
74
+ end
75
+ end
76
+
77
+ def coerce_integer(key, value)
78
+ return value if value.is_a?(Integer)
79
+
80
+ Integer(value.to_s, 10)
81
+ rescue ArgumentError, TypeError
82
+ raise CoercionError, "#{key} is not an integer"
83
+ end
84
+
85
+ def coerce_number(key, value)
86
+ return value if value.is_a?(Numeric)
87
+
88
+ Float(value.to_s)
89
+ rescue ArgumentError, TypeError
90
+ raise CoercionError, "#{key} is not a number"
91
+ end
92
+
93
+ def coerce_boolean(key, value)
94
+ case value
95
+ when true, false then value
96
+ when "true", "1", 1 then true
97
+ when "false", "0", 0 then false
98
+ else raise CoercionError, "#{key} is not a boolean"
99
+ end
100
+ end
101
+
102
+ def coerce_date(key, value)
103
+ return value if value.is_a?(Date) && !value.is_a?(DateTime)
104
+
105
+ Date.iso8601(value.to_s)
106
+ rescue ArgumentError, TypeError
107
+ raise CoercionError, "#{key} is not an ISO8601 date"
108
+ end
109
+
110
+ def coerce_datetime(key, value)
111
+ return value if value.is_a?(Time)
112
+
113
+ Time.iso8601(value.to_s)
114
+ rescue ArgumentError, TypeError
115
+ raise CoercionError, "#{key} is not an ISO8601 datetime"
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcpable
4
+ module SchemaStrategies
5
+ class ExplicitSchema < Ports::SchemaStrategy
6
+ JSON_TYPES = {
7
+ integer: "integer",
8
+ number: "number",
9
+ boolean: "boolean",
10
+ string: "string",
11
+ date: "string",
12
+ datetime: "string"
13
+ }.freeze
14
+
15
+ FORMATS = { date: "date", datetime: "date-time" }.freeze
16
+
17
+ PAGINATION_PROPERTIES = {
18
+ page: { type: "integer", description: "Page number, 1-based." },
19
+ per_page: { type: "integer", description: "Records per page." },
20
+ order: { type: "string", description: "Sort attribute, prefix with - for descending." }
21
+ }.freeze
22
+
23
+ def input_schema(definition)
24
+ properties = {}
25
+ required = []
26
+
27
+ definition.arguments.each do |argument|
28
+ properties[argument.name] = property_for(argument)
29
+ required << argument.name if argument.required
30
+ end
31
+
32
+ properties = properties.merge(PAGINATION_PROPERTIES) if definition.metadata[:paginated]
33
+
34
+ { type: "object", properties: properties, required: required }
35
+ end
36
+
37
+ private
38
+
39
+ def property_for(argument)
40
+ property = { type: JSON_TYPES.fetch(argument.type, "string") }
41
+ format = FORMATS[argument.type]
42
+ property[:format] = format if format
43
+ property[:description] = argument.description if argument.description
44
+ property[:enum] = argument.enum if argument.enum
45
+ property
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcpable
4
+ module Sources
5
+ class EnumerableSource < Ports::Source
6
+ DEFAULT_PER_PAGE = 25
7
+
8
+ def fetch(filters:, scope: nil, page: nil, per_page: nil, order: nil)
9
+ records = apply_scope(resolve_base, scope)
10
+ records = apply_filters(records, filters)
11
+ records = apply_order(records, order)
12
+
13
+ total = records.size
14
+ page = normalize_page(page)
15
+ per_page = normalize_per_page(per_page)
16
+
17
+ Page.new(
18
+ records: records.drop((page - 1) * per_page).take(per_page),
19
+ total: total,
20
+ page: page,
21
+ per_page: per_page
22
+ )
23
+ end
24
+
25
+ def find(id, scope: nil)
26
+ records = apply_scope(resolve_base, scope)
27
+ records.find { |record| read(record, :id).to_s == id.to_s }
28
+ end
29
+
30
+ private
31
+
32
+ def resolve_base
33
+ resolved = base.respond_to?(:call) ? base.call : base
34
+ resolved.to_a
35
+ end
36
+
37
+ def apply_scope(records, scope)
38
+ return records if scope.nil?
39
+ raise Error, "scope must be Enumerable" unless scope.is_a?(Enumerable)
40
+
41
+ records & scope.to_a
42
+ end
43
+
44
+ def apply_filters(records, filters)
45
+ (filters || {}).reduce(records) do |acc, (argument, value)|
46
+ apply_filter(acc, argument, value)
47
+ end
48
+ end
49
+
50
+ def apply_filter(records, argument, value)
51
+ return records if value.nil?
52
+
53
+ target = argument.filter_target || argument.name
54
+
55
+ case argument.filter_kind
56
+ when :eq then records.select { |r| read(r, target) == value }
57
+ when :match then records.select { |r| read(r, target).to_s.downcase.include?(value.to_s.downcase) }
58
+ when :range_from then records.select { |r| comparable?(read(r, target), value) && read(r, target) >= value }
59
+ when :range_to then records.select { |r| comparable?(read(r, target), value) && read(r, target) <= value }
60
+ when :scope then records
61
+ else records
62
+ end
63
+ end
64
+
65
+ def comparable?(left, right)
66
+ !left.nil? && left.respond_to?(:<=>) && !(left <=> right).nil?
67
+ end
68
+
69
+ def apply_order(records, order)
70
+ return records if order.nil? || order.to_s.empty?
71
+
72
+ raw = order.to_s
73
+ desc = raw.start_with?("-")
74
+ attribute = desc ? raw[1..] : raw
75
+ sorted = records.sort_by { |r| read(r, attribute) }
76
+ desc ? sorted.reverse : sorted
77
+ end
78
+
79
+ def normalize_page(page)
80
+ value = page.to_i
81
+ value < 1 ? 1 : value
82
+ end
83
+
84
+ def normalize_per_page(per_page)
85
+ value = per_page.to_i
86
+ value < 1 ? DEFAULT_PER_PAGE : value
87
+ end
88
+
89
+ def read(record, attribute)
90
+ name = attribute.to_sym
91
+ return record.public_send(name) if record.respond_to?(name)
92
+ return record[name] if record.respond_to?(:[]) && record.respond_to?(:key?) && record.key?(name)
93
+ return record[name.to_s] if record.respond_to?(:[]) && record.respond_to?(:key?) && record.key?(name.to_s)
94
+
95
+ nil
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcpable
4
+ module Tool
5
+ def self.included(base)
6
+ base.extend(ClassMethods)
7
+ end
8
+
9
+ attr_accessor :mcp_call
10
+
11
+ def current_user = mcp_call&.user
12
+
13
+ def current_scope = mcp_call&.scope
14
+
15
+ def current_context = mcp_call&.context || {}
16
+
17
+ module ClassMethods
18
+ attr_reader :mcp_definition
19
+
20
+ def mcp_tool(&block)
21
+ builder = Dsl::ToolBuilder.new(self)
22
+ Dsl.evaluate(builder, &block)
23
+ @mcp_definition = builder.compile
24
+ Mcpable.registry.register(@mcp_definition)
25
+ @mcp_definition
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcpable
4
+ class ToolCall
5
+ attr_reader :definition, :args, :context, :assigns
6
+
7
+ def initialize(definition:, args:, context:)
8
+ @definition = definition
9
+ @args = args
10
+ @context = context
11
+ @assigns = {}
12
+ end
13
+
14
+ def user = assigns[:user]
15
+
16
+ def scope = assigns[:scope]
17
+ end
18
+ end