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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +776 -0
- data/app/controllers/mcpable/rails/tools_controller.rb +23 -0
- data/lib/mcpable/active_record/column_inference.rb +43 -0
- data/lib/mcpable/active_record/source.rb +108 -0
- data/lib/mcpable/active_record.rb +15 -0
- data/lib/mcpable/argument.rb +29 -0
- data/lib/mcpable/definition.rb +36 -0
- data/lib/mcpable/dsl/resource_builder.rb +264 -0
- data/lib/mcpable/dsl/tool_builder.rb +66 -0
- data/lib/mcpable/dsl.rb +18 -0
- data/lib/mcpable/naming.rb +28 -0
- data/lib/mcpable/pipeline.rb +47 -0
- data/lib/mcpable/ports/middleware.rb +18 -0
- data/lib/mcpable/ports/schema_strategy.rb +11 -0
- data/lib/mcpable/ports/source.rb +23 -0
- data/lib/mcpable/ports/transport.rb +27 -0
- data/lib/mcpable/pundit/authorize.rb +31 -0
- data/lib/mcpable/pundit.rb +4 -0
- data/lib/mcpable/rails/engine.rb +29 -0
- data/lib/mcpable/rails/loader.rb +57 -0
- data/lib/mcpable/rails.rb +5 -0
- data/lib/mcpable/registry.rb +35 -0
- data/lib/mcpable/resource.rb +21 -0
- data/lib/mcpable/result.rb +17 -0
- data/lib/mcpable/runtime.rb +118 -0
- data/lib/mcpable/schema_strategies/explicit_schema.rb +49 -0
- data/lib/mcpable/sources/enumerable_source.rb +99 -0
- data/lib/mcpable/tool.rb +29 -0
- data/lib/mcpable/tool_call.rb +18 -0
- data/lib/mcpable/transports/official_mcp.rb +116 -0
- data/lib/mcpable/version.rb +5 -0
- data/lib/mcpable.rb +76 -0
- metadata +124 -0
|
@@ -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,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,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
|
data/lib/mcpable/tool.rb
ADDED
|
@@ -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
|