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 Rails
|
|
5
|
+
class ToolsController < ActionController::API
|
|
6
|
+
def create
|
|
7
|
+
transport = Mcpable::Transports::OfficialMcp.new(
|
|
8
|
+
registry: Mcpable.registry,
|
|
9
|
+
runtime: Mcpable.runtime,
|
|
10
|
+
profile: (params[:profile] || :default).to_sym
|
|
11
|
+
)
|
|
12
|
+
context = Mcpable.config.context_builder.call(request.env)
|
|
13
|
+
response_body = transport.handle(request.body.read, context: context)
|
|
14
|
+
|
|
15
|
+
if response_body.nil?
|
|
16
|
+
head :accepted
|
|
17
|
+
else
|
|
18
|
+
render json: response_body
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mcpable
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module ColumnInference
|
|
6
|
+
TYPE_MAP = {
|
|
7
|
+
integer: :integer,
|
|
8
|
+
bigint: :integer,
|
|
9
|
+
float: :number,
|
|
10
|
+
decimal: :number,
|
|
11
|
+
boolean: :boolean,
|
|
12
|
+
date: :date,
|
|
13
|
+
datetime: :datetime,
|
|
14
|
+
timestamp: :datetime
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def infer(model, name)
|
|
20
|
+
return nil unless model.respond_to?(:columns_hash)
|
|
21
|
+
|
|
22
|
+
enum = enum_values(model, name)
|
|
23
|
+
return { type: :string, enum: enum } if enum
|
|
24
|
+
|
|
25
|
+
column = model.columns_hash[name.to_s]
|
|
26
|
+
return nil if column.nil?
|
|
27
|
+
|
|
28
|
+
{ type: TYPE_MAP.fetch(column.type, :string), enum: nil }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def enum_values(model, name)
|
|
32
|
+
return nil unless model.respond_to?(:defined_enums)
|
|
33
|
+
|
|
34
|
+
values = model.defined_enums[name.to_s]
|
|
35
|
+
values && values.keys
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_proc
|
|
39
|
+
method(:infer).to_proc
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mcpable
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
class Source < Ports::Source
|
|
6
|
+
DEFAULT_PER_PAGE = 25
|
|
7
|
+
|
|
8
|
+
attr_reader :order_whitelist
|
|
9
|
+
|
|
10
|
+
def self.supports?(model)
|
|
11
|
+
model.respond_to?(:all) && model.respond_to?(:arel_table) && model.respond_to?(:columns_hash)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(base, order_whitelist: [])
|
|
15
|
+
super(base)
|
|
16
|
+
@order_whitelist = order_whitelist.map(&:to_s)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def fetch(filters:, scope: nil, page: nil, per_page: nil, order: nil)
|
|
20
|
+
relation = apply_scope(resolve_base, scope)
|
|
21
|
+
relation = apply_filters(relation, filters)
|
|
22
|
+
relation = apply_order(relation, order)
|
|
23
|
+
|
|
24
|
+
total = relation.count
|
|
25
|
+
page = normalize_page(page)
|
|
26
|
+
per_page = normalize_per_page(per_page)
|
|
27
|
+
|
|
28
|
+
Page.new(
|
|
29
|
+
records: relation.offset((page - 1) * per_page).limit(per_page).to_a,
|
|
30
|
+
total: total,
|
|
31
|
+
page: page,
|
|
32
|
+
per_page: per_page
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def find(id, scope: nil)
|
|
37
|
+
apply_scope(resolve_base, scope).find_by(id: id)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def resolve_base
|
|
43
|
+
resolved = base.respond_to?(:call) ? base.call : base
|
|
44
|
+
resolved.respond_to?(:all) ? resolved.all : resolved
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def apply_scope(relation, scope)
|
|
48
|
+
scope.nil? ? relation : relation.merge(scope)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def apply_filters(relation, filters)
|
|
52
|
+
(filters || {}).reduce(relation) do |acc, (argument, value)|
|
|
53
|
+
apply_filter(acc, argument, value)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def apply_filter(relation, argument, value)
|
|
58
|
+
return relation if value.nil?
|
|
59
|
+
|
|
60
|
+
target = (argument.filter_target || argument.name).to_s
|
|
61
|
+
|
|
62
|
+
case argument.filter_kind
|
|
63
|
+
when :eq then relation.where(target => value)
|
|
64
|
+
when :match then apply_match(relation, target, value)
|
|
65
|
+
when :range_from then relation.where(arel_column(relation, target).gteq(value))
|
|
66
|
+
when :range_to then relation.where(arel_column(relation, target).lteq(value))
|
|
67
|
+
when :scope then value ? relation.public_send(target) : relation
|
|
68
|
+
else relation
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def apply_match(relation, target, value)
|
|
73
|
+
pattern = "%#{escape_like(relation, value.to_s)}%"
|
|
74
|
+
relation.where(arel_column(relation, target).matches(pattern, nil, false))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def arel_column(relation, target)
|
|
78
|
+
relation.klass.arel_table[target]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def escape_like(relation, value)
|
|
82
|
+
model = relation.klass
|
|
83
|
+
model.respond_to?(:sanitize_sql_like) ? model.sanitize_sql_like(value) : value
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def apply_order(relation, order)
|
|
87
|
+
return relation if order.nil? || order.to_s.empty?
|
|
88
|
+
|
|
89
|
+
raw = order.to_s
|
|
90
|
+
desc = raw.start_with?("-")
|
|
91
|
+
column = desc ? raw[1..] : raw
|
|
92
|
+
return relation unless order_whitelist.include?(column)
|
|
93
|
+
|
|
94
|
+
relation.order(column => desc ? :desc : :asc)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def normalize_page(page)
|
|
98
|
+
value = page.to_i
|
|
99
|
+
value < 1 ? 1 : value
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def normalize_per_page(per_page)
|
|
103
|
+
value = per_page.to_i
|
|
104
|
+
value < 1 ? DEFAULT_PER_PAGE : value
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcpable"
|
|
4
|
+
require "mcpable/active_record/column_inference"
|
|
5
|
+
require "mcpable/active_record/source"
|
|
6
|
+
|
|
7
|
+
Mcpable::Dsl::ResourceBuilder.type_inferrer = lambda do |model, name|
|
|
8
|
+
Mcpable::ActiveRecord::ColumnInference.infer(model, name)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
Mcpable::Dsl::ResourceBuilder.source_factory = lambda do |model, order_whitelist:|
|
|
12
|
+
next nil unless Mcpable::ActiveRecord::Source.supports?(model)
|
|
13
|
+
|
|
14
|
+
Mcpable::ActiveRecord::Source.new(model, order_whitelist: order_whitelist)
|
|
15
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mcpable
|
|
4
|
+
Argument = Data.define(
|
|
5
|
+
:name,
|
|
6
|
+
:type,
|
|
7
|
+
:required,
|
|
8
|
+
:description,
|
|
9
|
+
:enum,
|
|
10
|
+
:filter
|
|
11
|
+
) do
|
|
12
|
+
def self.build(name, type:, required: false, description: nil, enum: nil, filter: nil)
|
|
13
|
+
new(
|
|
14
|
+
name: name.to_sym,
|
|
15
|
+
type: type.to_sym,
|
|
16
|
+
required: required,
|
|
17
|
+
description: description,
|
|
18
|
+
enum: enum,
|
|
19
|
+
filter: filter
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def filter? = !filter.nil?
|
|
24
|
+
|
|
25
|
+
def filter_kind = filter && filter[:kind]
|
|
26
|
+
|
|
27
|
+
def filter_target = filter && filter[:target]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mcpable
|
|
4
|
+
Definition = Data.define(
|
|
5
|
+
:name,
|
|
6
|
+
:description,
|
|
7
|
+
:arguments,
|
|
8
|
+
:handler,
|
|
9
|
+
:annotations,
|
|
10
|
+
:profiles,
|
|
11
|
+
:metadata
|
|
12
|
+
) do
|
|
13
|
+
def self.build(name:, description: nil, arguments: [], handler: nil, annotations: {},
|
|
14
|
+
profiles: [:default], metadata: {})
|
|
15
|
+
new(
|
|
16
|
+
name: name.to_s,
|
|
17
|
+
description: description,
|
|
18
|
+
arguments: arguments,
|
|
19
|
+
handler: handler,
|
|
20
|
+
annotations: annotations,
|
|
21
|
+
profiles: profiles,
|
|
22
|
+
metadata: metadata
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def read_only? = annotations.fetch(:read_only, true)
|
|
27
|
+
|
|
28
|
+
def destructive? = annotations.fetch(:destructive, false)
|
|
29
|
+
|
|
30
|
+
def open_world? = annotations.fetch(:open_world, false)
|
|
31
|
+
|
|
32
|
+
def profile?(profile) = profiles.include?(profile)
|
|
33
|
+
|
|
34
|
+
def argument(name) = arguments.find { |a| a.name == name.to_sym }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mcpable
|
|
4
|
+
module Dsl
|
|
5
|
+
class ResourceBuilder
|
|
6
|
+
class << self
|
|
7
|
+
attr_accessor :type_inferrer, :source_factory
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
DEFAULT_PER_PAGE = 25
|
|
11
|
+
SUPPORTED_ACTIONS = %i[list show].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(target)
|
|
14
|
+
@target = target
|
|
15
|
+
@base_name = Naming.underscore(target.name.to_s.split("::").last)
|
|
16
|
+
@description = nil
|
|
17
|
+
@attributes = []
|
|
18
|
+
@conditional_attributes = []
|
|
19
|
+
@filters = []
|
|
20
|
+
@source = nil
|
|
21
|
+
@policy = nil
|
|
22
|
+
@actions = %i[list show]
|
|
23
|
+
@order_whitelist = nil
|
|
24
|
+
@per_page = DEFAULT_PER_PAGE
|
|
25
|
+
@profiles = [:default]
|
|
26
|
+
@annotations = { read_only: true }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def name(value) = @base_name = Naming.underscore(value.to_s)
|
|
30
|
+
|
|
31
|
+
def description(value) = @description = value
|
|
32
|
+
|
|
33
|
+
def attributes(*values) = @attributes = values.flatten.map(&:to_sym)
|
|
34
|
+
|
|
35
|
+
def attribute(name, **options)
|
|
36
|
+
condition = options[:if]
|
|
37
|
+
return @attributes |= [name.to_sym] if condition.nil?
|
|
38
|
+
|
|
39
|
+
@conditional_attributes << { name: name.to_sym, condition: condition }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def filter(name, type: nil, match: nil, range: false, required: false, description: nil,
|
|
43
|
+
enum: nil, scope: false)
|
|
44
|
+
@filters << {
|
|
45
|
+
name: name.to_sym,
|
|
46
|
+
type: type,
|
|
47
|
+
match: match,
|
|
48
|
+
range: range,
|
|
49
|
+
required: required,
|
|
50
|
+
description: description,
|
|
51
|
+
enum: enum,
|
|
52
|
+
scope: scope
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def source(value) = @source = value
|
|
57
|
+
|
|
58
|
+
def order_whitelist(*values) = @order_whitelist = values.flatten.map(&:to_sym)
|
|
59
|
+
|
|
60
|
+
def policy(value) = @policy = value
|
|
61
|
+
|
|
62
|
+
def actions(*values) = @actions = values.flatten.map(&:to_sym)
|
|
63
|
+
|
|
64
|
+
def default_page_size(value) = @per_page = Integer(value)
|
|
65
|
+
|
|
66
|
+
def profiles(*values) = @profiles = values.flatten.map(&:to_sym)
|
|
67
|
+
|
|
68
|
+
def annotations(**values) = @annotations = @annotations.merge(values)
|
|
69
|
+
|
|
70
|
+
def compile
|
|
71
|
+
validate_actions!
|
|
72
|
+
validate_conditional_attributes!
|
|
73
|
+
|
|
74
|
+
@source ||= build_default_source
|
|
75
|
+
raise ArgumentError, "#{@target} needs a source" if @source.nil?
|
|
76
|
+
|
|
77
|
+
definitions = []
|
|
78
|
+
definitions << compile_list if @actions.include?(:list)
|
|
79
|
+
definitions << compile_show if @actions.include?(:show)
|
|
80
|
+
definitions
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def validate_conditional_attributes!
|
|
86
|
+
hidden = @conditional_attributes.map { |a| a[:name] }
|
|
87
|
+
return if hidden.empty?
|
|
88
|
+
|
|
89
|
+
leaked = @filters.map { |f| f[:name] } & hidden
|
|
90
|
+
return if leaked.empty?
|
|
91
|
+
|
|
92
|
+
raise ArgumentError,
|
|
93
|
+
"#{@target} filters on conditionally visible attributes: #{leaked.join(', ')} " \
|
|
94
|
+
"(a filter would expose them to callers that cannot read them)"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def validate_actions!
|
|
98
|
+
raise ArgumentError, "#{@target} declares no actions" if @actions.empty?
|
|
99
|
+
|
|
100
|
+
unsupported = @actions - SUPPORTED_ACTIONS
|
|
101
|
+
return if unsupported.empty?
|
|
102
|
+
|
|
103
|
+
raise ArgumentError,
|
|
104
|
+
"#{@target} declares unsupported actions: #{unsupported.join(', ')} " \
|
|
105
|
+
"(supported: #{SUPPORTED_ACTIONS.join(', ')}; write actions are not implemented yet)"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def build_default_source
|
|
109
|
+
factory = self.class.source_factory
|
|
110
|
+
factory&.call(@target, order_whitelist: @order_whitelist || @attributes)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def list_name = "#{Naming.pluralize(@base_name)}_list"
|
|
114
|
+
|
|
115
|
+
def show_name = "#{Naming.pluralize(@base_name)}_show"
|
|
116
|
+
|
|
117
|
+
def compile_list
|
|
118
|
+
arguments = list_arguments
|
|
119
|
+
attributes = @attributes
|
|
120
|
+
conditional = @conditional_attributes
|
|
121
|
+
source_ref = @source
|
|
122
|
+
per_page = @per_page
|
|
123
|
+
|
|
124
|
+
handler = lambda do |ctx|
|
|
125
|
+
visible = ResourceBuilder.visible_attributes(attributes, conditional, ctx)
|
|
126
|
+
source = ResourceBuilder.resolve_source(source_ref)
|
|
127
|
+
filters = arguments.select(&:filter?).to_h { |a| [a, ctx.args[a.name]] }
|
|
128
|
+
page = source.fetch(
|
|
129
|
+
filters: filters,
|
|
130
|
+
scope: ctx.scope,
|
|
131
|
+
page: ctx.args[:page] || 1,
|
|
132
|
+
per_page: ctx.args[:per_page] || per_page,
|
|
133
|
+
order: ctx.args[:order]
|
|
134
|
+
)
|
|
135
|
+
Result.ok(
|
|
136
|
+
records: page.records.map { |r| ResourceBuilder.serialize(r, visible) },
|
|
137
|
+
total: page.total,
|
|
138
|
+
page: page.page,
|
|
139
|
+
per_page: page.per_page
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
Definition.build(
|
|
144
|
+
name: list_name,
|
|
145
|
+
description: @description,
|
|
146
|
+
arguments: arguments,
|
|
147
|
+
handler: handler,
|
|
148
|
+
annotations: @annotations,
|
|
149
|
+
profiles: @profiles,
|
|
150
|
+
metadata: {
|
|
151
|
+
model: @target,
|
|
152
|
+
action: :list,
|
|
153
|
+
policy: @policy,
|
|
154
|
+
per_page: @per_page,
|
|
155
|
+
paginated: true
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def compile_show
|
|
161
|
+
attributes = @attributes
|
|
162
|
+
conditional = @conditional_attributes
|
|
163
|
+
source_ref = @source
|
|
164
|
+
|
|
165
|
+
handler = lambda do |ctx|
|
|
166
|
+
source = ResourceBuilder.resolve_source(source_ref)
|
|
167
|
+
record = source.find(ctx.args[:id], scope: ctx.scope)
|
|
168
|
+
next Result.fail("not found") if record.nil?
|
|
169
|
+
|
|
170
|
+
visible = ResourceBuilder.visible_attributes(attributes, conditional, ctx)
|
|
171
|
+
Result.ok(ResourceBuilder.serialize(record, visible))
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
Definition.build(
|
|
175
|
+
name: show_name,
|
|
176
|
+
description: @description,
|
|
177
|
+
arguments: [Argument.build(:id, type: :integer, required: true, description: "Record id.")],
|
|
178
|
+
handler: handler,
|
|
179
|
+
annotations: @annotations,
|
|
180
|
+
profiles: @profiles,
|
|
181
|
+
metadata: {
|
|
182
|
+
model: @target,
|
|
183
|
+
action: :show,
|
|
184
|
+
policy: @policy
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def list_arguments
|
|
190
|
+
@filters.flat_map { |spec| expand_filter(spec) }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def expand_filter(spec)
|
|
194
|
+
type, enum = resolve_type(spec)
|
|
195
|
+
|
|
196
|
+
if spec[:range]
|
|
197
|
+
[
|
|
198
|
+
build_argument("#{spec[:name]}_from", spec, type, enum, :range_from),
|
|
199
|
+
build_argument("#{spec[:name]}_to", spec, type, enum, :range_to)
|
|
200
|
+
]
|
|
201
|
+
elsif spec[:scope]
|
|
202
|
+
[build_argument(spec[:name], spec, type, enum, :scope)]
|
|
203
|
+
elsif spec[:match] == :partial
|
|
204
|
+
[build_argument(spec[:name], spec, type, enum, :match)]
|
|
205
|
+
else
|
|
206
|
+
[build_argument(spec[:name], spec, type, enum, :eq)]
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def build_argument(name, spec, type, enum, kind)
|
|
211
|
+
Argument.build(
|
|
212
|
+
name,
|
|
213
|
+
type: type,
|
|
214
|
+
required: kind == :eq ? spec[:required] : false,
|
|
215
|
+
description: spec[:description],
|
|
216
|
+
enum: enum,
|
|
217
|
+
filter: { kind: kind, target: spec[:name] }
|
|
218
|
+
)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def resolve_type(spec)
|
|
222
|
+
return [spec[:type].to_sym, spec[:enum]] if spec[:type]
|
|
223
|
+
|
|
224
|
+
inferrer = self.class.type_inferrer
|
|
225
|
+
inferred = inferrer&.call(@target, spec[:name])
|
|
226
|
+
raise ArgumentError, "filter :#{spec[:name]} needs type:" if inferred.nil?
|
|
227
|
+
|
|
228
|
+
[inferred[:type], spec[:enum] || inferred[:enum]]
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def self.resolve_source(source_ref)
|
|
232
|
+
return source_ref if source_ref.respond_to?(:fetch)
|
|
233
|
+
|
|
234
|
+
source_ref.respond_to?(:call) ? source_ref.call : source_ref
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def self.visible_attributes(base, conditional, ctx)
|
|
238
|
+
return base if conditional.empty?
|
|
239
|
+
|
|
240
|
+
base + conditional.select { |a| visible?(a[:condition], ctx) }.map { |a| a[:name] }
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def self.visible?(condition, ctx)
|
|
244
|
+
case condition
|
|
245
|
+
when Symbol then ctx.user.respond_to?(condition) && !!ctx.user.public_send(condition)
|
|
246
|
+
else condition.arity.zero? ? !!condition.call : !!condition.call(ctx)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def self.serialize(record, attributes)
|
|
251
|
+
attributes.each_with_object({}) do |attribute, out|
|
|
252
|
+
out[attribute] = read(record, attribute)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def self.read(record, attribute)
|
|
257
|
+
return record.public_send(attribute) if record.respond_to?(attribute)
|
|
258
|
+
return record[attribute] if record.respond_to?(:[])
|
|
259
|
+
|
|
260
|
+
nil
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mcpable
|
|
4
|
+
module Dsl
|
|
5
|
+
class ToolBuilder
|
|
6
|
+
def initialize(target)
|
|
7
|
+
@target = target
|
|
8
|
+
@name = default_name(target)
|
|
9
|
+
@description = nil
|
|
10
|
+
@arguments = []
|
|
11
|
+
@annotations = {}
|
|
12
|
+
@profiles = [:default]
|
|
13
|
+
@metadata = {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def name(value) = @name = value.to_s
|
|
17
|
+
|
|
18
|
+
def description(value) = @description = value
|
|
19
|
+
|
|
20
|
+
def argument(name, type, required: false, description: nil, enum: nil)
|
|
21
|
+
@arguments << Argument.build(
|
|
22
|
+
name,
|
|
23
|
+
type: type,
|
|
24
|
+
required: required,
|
|
25
|
+
description: description,
|
|
26
|
+
enum: enum
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def annotations(**values) = @annotations = @annotations.merge(values)
|
|
31
|
+
|
|
32
|
+
def profiles(*values) = @profiles = values.flatten.map(&:to_sym)
|
|
33
|
+
|
|
34
|
+
def metadata(**values) = @metadata = @metadata.merge(values)
|
|
35
|
+
|
|
36
|
+
def compile
|
|
37
|
+
target = @target
|
|
38
|
+
argument_names = @arguments.map(&:name)
|
|
39
|
+
|
|
40
|
+
handler = lambda do |ctx|
|
|
41
|
+
call_args = ctx.args.slice(*argument_names)
|
|
42
|
+
instance = target.new
|
|
43
|
+
instance.mcp_call = ctx if instance.respond_to?(:mcp_call=)
|
|
44
|
+
value = instance.call(**call_args)
|
|
45
|
+
value.is_a?(Result) ? value : Result.ok(value)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Definition.build(
|
|
49
|
+
name: @name,
|
|
50
|
+
description: @description,
|
|
51
|
+
arguments: @arguments,
|
|
52
|
+
handler: handler,
|
|
53
|
+
annotations: @annotations,
|
|
54
|
+
profiles: @profiles,
|
|
55
|
+
metadata: @metadata
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def default_name(target)
|
|
62
|
+
Naming.underscore(target.name.to_s.gsub("::", "_"))
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
data/lib/mcpable/dsl.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mcpable
|
|
4
|
+
module Dsl
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def evaluate(builder, &block)
|
|
8
|
+
return builder if block.nil?
|
|
9
|
+
|
|
10
|
+
if block.arity == 1
|
|
11
|
+
block.call(builder)
|
|
12
|
+
else
|
|
13
|
+
builder.instance_eval(&block)
|
|
14
|
+
end
|
|
15
|
+
builder
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mcpable
|
|
4
|
+
module Naming
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def underscore(value)
|
|
8
|
+
value
|
|
9
|
+
.to_s
|
|
10
|
+
.gsub("::", "_")
|
|
11
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
12
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
13
|
+
.tr("-", "_")
|
|
14
|
+
.downcase
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def pluralize(value)
|
|
18
|
+
word = value.to_s
|
|
19
|
+
case word
|
|
20
|
+
when /(?:s|x|z|ch|sh)\z/ then "#{word}es"
|
|
21
|
+
when /[^aeiou]y\z/ then "#{word[0..-2]}ies"
|
|
22
|
+
when /f\z/ then "#{word[0..-2]}ves"
|
|
23
|
+
when /fe\z/ then "#{word[0..-3]}ves"
|
|
24
|
+
else "#{word}s"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mcpable
|
|
4
|
+
class Pipeline
|
|
5
|
+
HANDLER = lambda do |ctx|
|
|
6
|
+
handler = ctx.definition.handler
|
|
7
|
+
raise Error, "definition #{ctx.definition.name} has no handler" if handler.nil?
|
|
8
|
+
|
|
9
|
+
handler.call(ctx)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
attr_reader :middlewares
|
|
13
|
+
|
|
14
|
+
def initialize(config: nil)
|
|
15
|
+
@middlewares = []
|
|
16
|
+
@config = config
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def use(mw, *args)
|
|
20
|
+
@middlewares << [mw, args]
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def clear
|
|
25
|
+
@middlewares = []
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call(ctx)
|
|
30
|
+
result = build_stack.call(ctx)
|
|
31
|
+
result.is_a?(Result) ? result : Result.fail("invalid result")
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
mapped = error_mapper.call(e)
|
|
34
|
+
mapped.is_a?(Result) ? mapped : Result.fail("invalid result")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def error_mapper = (@config || Mcpable.config).error_mapper
|
|
40
|
+
|
|
41
|
+
def build_stack
|
|
42
|
+
@middlewares.reverse.reduce(HANDLER) do |app, (mw, args)|
|
|
43
|
+
mw.new(app, *args)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|