mcp_toolkit 0.3.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/.github/workflows/ci.yml +43 -0
- data/.rubocop.yml +98 -0
- data/CHANGELOG.md +123 -0
- data/LICENSE.txt +21 -0
- data/README.md +301 -0
- data/Rakefile +21 -0
- data/app/controllers/mcp_toolkit/server_controller.rb +19 -0
- data/config/routes.rb +18 -0
- data/lib/mcp_toolkit/auth/authenticator.rb +99 -0
- data/lib/mcp_toolkit/auth/authority.rb +75 -0
- data/lib/mcp_toolkit/auth/authority_server_client.rb +50 -0
- data/lib/mcp_toolkit/auth/introspection.rb +162 -0
- data/lib/mcp_toolkit/configuration.rb +209 -0
- data/lib/mcp_toolkit/engine.rb +21 -0
- data/lib/mcp_toolkit/errors/base.rb +10 -0
- data/lib/mcp_toolkit/errors/configuration_error.rb +5 -0
- data/lib/mcp_toolkit/errors/invalid_params.rb +5 -0
- data/lib/mcp_toolkit/errors/unauthorized.rb +5 -0
- data/lib/mcp_toolkit/field_selection.rb +93 -0
- data/lib/mcp_toolkit/filtering.rb +152 -0
- data/lib/mcp_toolkit/get_executor.rb +32 -0
- data/lib/mcp_toolkit/list_executor.rb +137 -0
- data/lib/mcp_toolkit/registry.rb +67 -0
- data/lib/mcp_toolkit/resource.rb +129 -0
- data/lib/mcp_toolkit/resource_schema.rb +163 -0
- data/lib/mcp_toolkit/serialization.rb +62 -0
- data/lib/mcp_toolkit/serializer/base.rb +285 -0
- data/lib/mcp_toolkit/server.rb +44 -0
- data/lib/mcp_toolkit/session.rb +46 -0
- data/lib/mcp_toolkit/sql_sanitizer.rb +14 -0
- data/lib/mcp_toolkit/token_kinds.rb +14 -0
- data/lib/mcp_toolkit/tools/base.rb +100 -0
- data/lib/mcp_toolkit/tools/get.rb +57 -0
- data/lib/mcp_toolkit/tools/list.rb +83 -0
- data/lib/mcp_toolkit/tools/resource_schema.rb +40 -0
- data/lib/mcp_toolkit/tools/resources.rb +27 -0
- data/lib/mcp_toolkit/transport/controller_methods.rb +226 -0
- data/lib/mcp_toolkit/unknown_resource_message.rb +75 -0
- data/lib/mcp_toolkit/version.rb +5 -0
- data/lib/mcp_toolkit.rb +118 -0
- data/sig/mcp_toolkit.rbs +4 -0
- metadata +147 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A parsed, validated sparse-fieldset request (JSON:API's `fields[type]`) for a
|
|
4
|
+
# single resource. Requested names share ONE flat namespace covering both declared
|
|
5
|
+
# ATTRIBUTES and relationship link keys — exactly as JSON:API conflates them.
|
|
6
|
+
#
|
|
7
|
+
# Built by the List/Get executors from the tool's `fields` argument. A nil
|
|
8
|
+
# selection (blank/absent `fields`) means "all fields": the executors skip it
|
|
9
|
+
# entirely and serialize as before, so the default path is completely untouched.
|
|
10
|
+
#
|
|
11
|
+
# A present selection is applied one of two ways, decided by McpToolkit::Serialization:
|
|
12
|
+
# * NATIVELY — `names` is passed to a serializer whose `serialize_one` /
|
|
13
|
+
# `serialize_collection` declares a `fields:` keyword (the gem's Base does),
|
|
14
|
+
# so unselected attributes and relationships are never computed at all.
|
|
15
|
+
# * By PRUNING the fully-serialized hash (`prune_record` / `prune_collection`)
|
|
16
|
+
# for an injected serializer that predates the `fields:` kwarg — a pure
|
|
17
|
+
# shape-level filter, so ANY contract-satisfying serializer stays sparse-able.
|
|
18
|
+
class McpToolkit::FieldSelection
|
|
19
|
+
LINKS_KEY = "links"
|
|
20
|
+
|
|
21
|
+
# Parse the raw tool argument (a comma-separated string OR an array of names)
|
|
22
|
+
# into a selection, or nil when nothing was requested. Validates the requested
|
|
23
|
+
# names against the resource's known members when the resource can describe them
|
|
24
|
+
# (the Base serializer exposes `declared_attributes`); an unknown name is a
|
|
25
|
+
# clean InvalidParams so a typo is actionable rather than silently dropped.
|
|
26
|
+
def self.build(resource:, raw:)
|
|
27
|
+
names = parse(raw)
|
|
28
|
+
return nil if names.empty?
|
|
29
|
+
|
|
30
|
+
new(resource:, names:).tap(&:validate!)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Normalizes a CSV string or an array into a de-duplicated list of symbols.
|
|
34
|
+
def self.parse(raw)
|
|
35
|
+
list = raw.is_a?(Array) ? raw : raw.to_s.split(",")
|
|
36
|
+
list.map { |name| name.to_s.strip }.reject(&:empty?).map(&:to_sym).uniq
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The validated requested field names (symbols), passed to a fields-aware
|
|
40
|
+
# serializer as its `fields:` argument.
|
|
41
|
+
attr_reader :names
|
|
42
|
+
|
|
43
|
+
def initialize(resource:, names:)
|
|
44
|
+
@resource = resource
|
|
45
|
+
@names = names
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Raises InvalidParams if any requested name is neither a declared attribute nor
|
|
49
|
+
# a relationship link key. Skipped for serializers that can't describe their
|
|
50
|
+
# members (resource_schema degrades the same way) — those are pruned leniently.
|
|
51
|
+
def validate!
|
|
52
|
+
return unless @resource.serializer.respond_to?(:declared_attributes)
|
|
53
|
+
|
|
54
|
+
unknown = @names - known_members
|
|
55
|
+
return if unknown.empty?
|
|
56
|
+
|
|
57
|
+
selectable = known_members.map(&:to_s).sort.join(", ").presence || "(none)"
|
|
58
|
+
raise McpToolkit::Errors::InvalidParams,
|
|
59
|
+
"unknown field(s): #{unknown.join(", ")}. Selectable fields for this resource: #{selectable}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Prune a single serialized record hash down to the requested members.
|
|
63
|
+
# Shape-driven (needs no serializer metadata): every key is an attribute except
|
|
64
|
+
# the string `"links"` block, which is itself narrowed to the requested link
|
|
65
|
+
# keys and dropped entirely when nothing under it was requested.
|
|
66
|
+
def prune_record(hash)
|
|
67
|
+
requested = @names.map(&:to_s)
|
|
68
|
+
hash.each_with_object({}) do |(key, value), pruned|
|
|
69
|
+
if key.to_s == LINKS_KEY
|
|
70
|
+
links = (value || {}).select { |link_key, _| requested.include?(link_key.to_s) }
|
|
71
|
+
pruned[LINKS_KEY] = links unless links.empty?
|
|
72
|
+
elsif requested.include?(key.to_s)
|
|
73
|
+
pruned[key] = value
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Prune the collection wrapper: each array value holds the rows (prune each);
|
|
79
|
+
# the `meta` hash and any other non-array entry pass through untouched.
|
|
80
|
+
def prune_collection(wrapper)
|
|
81
|
+
wrapper.transform_values do |value|
|
|
82
|
+
value.is_a?(Array) ? value.map { |row| prune_record(row) } : value
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def known_members
|
|
89
|
+
@known_members ||=
|
|
90
|
+
@resource.attribute_names.map(&:to_sym) +
|
|
91
|
+
@resource.association_descriptors.map { |assoc| assoc.links_key.to_sym }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Applies the `list` tool's `filter` params to an already-scoped relation, with
|
|
4
|
+
# the following semantics:
|
|
5
|
+
#
|
|
6
|
+
# * a BARE value filters by equality: filter: { booking_id: 42 }
|
|
7
|
+
# (a comma-separated string becomes an IN lookup: filter: { status: "a,b" })
|
|
8
|
+
# * an { op:, value: } HASH filters with an operator: filter: { price: { op: "gteq", value: 100 } }
|
|
9
|
+
# * an ARRAY of those hashes ANDs them (ranges):
|
|
10
|
+
# filter: { price: [{ op: "gteq", value: 100 }, { op: "lt", value: 200 }] }
|
|
11
|
+
#
|
|
12
|
+
# Supported operators (validated against the column's DB type):
|
|
13
|
+
# eq, not_eq, gt, gteq, lt, lteq — numeric / datetime columns
|
|
14
|
+
# eq, not_eq, in, matches, does_not_match — string columns (matches => case-insensitive LIKE)
|
|
15
|
+
# eq, not_eq — boolean columns
|
|
16
|
+
#
|
|
17
|
+
# Allowlist-safe: only the resource's declared `filterable` keys may be filtered,
|
|
18
|
+
# each resolved to its backing column. Unknown keys are rejected upstream by the
|
|
19
|
+
# ListExecutor, and an operator unsupported for a column's type raises
|
|
20
|
+
# InvalidParams — there is no arbitrary column or SQL injection surface.
|
|
21
|
+
module McpToolkit::Filtering
|
|
22
|
+
OPERATORS_BY_TYPE = {
|
|
23
|
+
integer: %w[eq not_eq gt gteq lt lteq].freeze,
|
|
24
|
+
float: %w[eq not_eq gt gteq lt lteq].freeze,
|
|
25
|
+
decimal: %w[eq not_eq gt gteq lt lteq].freeze,
|
|
26
|
+
datetime: %w[eq not_eq gt gteq lt lteq].freeze,
|
|
27
|
+
date: %w[eq not_eq gt gteq lt lteq].freeze,
|
|
28
|
+
string: %w[eq not_eq in matches does_not_match].freeze,
|
|
29
|
+
text: %w[eq not_eq in matches does_not_match].freeze,
|
|
30
|
+
boolean: %w[eq not_eq].freeze
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# Operators that map straight onto an Arel predication method.
|
|
34
|
+
AREL_PREDICATIONS = %w[eq not_eq gt gteq lt lteq in matches does_not_match].freeze
|
|
35
|
+
|
|
36
|
+
NULL_TOKEN = "null"
|
|
37
|
+
|
|
38
|
+
# @param relation the already account-scoped relation
|
|
39
|
+
# @param column [Symbol] the backing DB column (already resolved from the allowlist)
|
|
40
|
+
# @param value the filter value: a bare value, an { op:, value: } hash, or an
|
|
41
|
+
# array of such hashes
|
|
42
|
+
# @param config [McpToolkit::Configuration] supplies the SQL sanitizer used to
|
|
43
|
+
# escape LIKE wildcards
|
|
44
|
+
# @return the relation with the filter(s) applied
|
|
45
|
+
def self.apply(relation, column, value, config: McpToolkit.config)
|
|
46
|
+
if compound?(value)
|
|
47
|
+
apply_condition(relation, column, value, config:)
|
|
48
|
+
elsif collection?(value)
|
|
49
|
+
value.inject(relation) { |rel, condition| apply_condition(rel, column, condition, config:) }
|
|
50
|
+
else
|
|
51
|
+
# Bare value: equality. A comma-separated string becomes an IN lookup,
|
|
52
|
+
# matching the implicit `eq` semantics.
|
|
53
|
+
relation.where(column => equality_value(value))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A single operator-based condition, e.g. { op: "gt", value: 1000 }.
|
|
58
|
+
def self.compound?(value)
|
|
59
|
+
condition_hash?(value) && (value.key?(:op) || value.key?("op"))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Several operator-based conditions on one attribute, ANDed (ranges).
|
|
63
|
+
def self.collection?(value)
|
|
64
|
+
value.is_a?(Array) && value.any? && value.all? { |element| compound?(element) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.condition_hash?(value)
|
|
68
|
+
value.is_a?(Hash)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.apply_condition(relation, column, condition, config:)
|
|
72
|
+
operator = fetch(condition, :op).to_s
|
|
73
|
+
raise McpToolkit::Errors::InvalidParams, "a filter operator is required" if operator.empty?
|
|
74
|
+
|
|
75
|
+
type = column_type(relation, column)
|
|
76
|
+
validate_operator!(operator, type, column)
|
|
77
|
+
|
|
78
|
+
raw = fetch(condition, :value)
|
|
79
|
+
relation.where(predicate_for(relation, column, operator, raw, config:))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Reads a key regardless of symbol/string keys; checks presence (not
|
|
83
|
+
# truthiness) so a literal `false` / `nil` value survives.
|
|
84
|
+
def self.fetch(condition, key)
|
|
85
|
+
return condition[key] if condition.key?(key)
|
|
86
|
+
|
|
87
|
+
condition[key.to_s] if condition.key?(key.to_s)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.column_type(relation, column)
|
|
91
|
+
model = relation.respond_to?(:model) ? relation.model : nil
|
|
92
|
+
return nil unless model.respond_to?(:columns_hash)
|
|
93
|
+
|
|
94
|
+
model.columns_hash[column.to_s]&.type
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.validate_operator!(operator, type, column)
|
|
98
|
+
allowed = OPERATORS_BY_TYPE[type]
|
|
99
|
+
if allowed.nil?
|
|
100
|
+
raise McpToolkit::Errors::InvalidParams,
|
|
101
|
+
"'#{column}' cannot be filtered with operators"
|
|
102
|
+
end
|
|
103
|
+
return if allowed.include?(operator)
|
|
104
|
+
|
|
105
|
+
raise McpToolkit::Errors::InvalidParams,
|
|
106
|
+
"'#{operator}' operator is not supported for #{column} (#{type}). " \
|
|
107
|
+
"Supported operators: #{allowed.join(", ")}."
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Builds the predicate to hand to `relation.where`. For a real ActiveRecord
|
|
111
|
+
# relation we build an Arel node (so it composes with the scope safely and is
|
|
112
|
+
# immune to SQL injection); a relation without an `arel_table` (the in-memory
|
|
113
|
+
# test fake) receives a portable Predicate value object it knows how to apply.
|
|
114
|
+
def self.predicate_for(relation, column, operator, raw, config:)
|
|
115
|
+
value = normalize_value(operator, raw, config:)
|
|
116
|
+
arel_operator = operator == "eq" ? "in" : operator
|
|
117
|
+
|
|
118
|
+
model = relation.respond_to?(:model) ? relation.model : nil
|
|
119
|
+
if model.respond_to?(:arel_table)
|
|
120
|
+
model.arel_table[column.to_sym].public_send(arel_operator, value)
|
|
121
|
+
else
|
|
122
|
+
Predicate.new(column.to_sym, arel_operator, value)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# `eq` against a (possibly comma-separated) value becomes an IN set. `matches`
|
|
127
|
+
# / `does_not_match` wrap the value in `%...%` with LIKE wildcards escaped so
|
|
128
|
+
# they match literally. `null` => nil for every operator.
|
|
129
|
+
def self.normalize_value(operator, raw, config:)
|
|
130
|
+
return nil if raw.to_s == NULL_TOKEN
|
|
131
|
+
|
|
132
|
+
case operator
|
|
133
|
+
when "eq", "in"
|
|
134
|
+
raw.to_s.split(",")
|
|
135
|
+
when "matches", "does_not_match"
|
|
136
|
+
"%#{config.sql_sanitizer.sanitize_sql_like(raw.to_s)}%"
|
|
137
|
+
else
|
|
138
|
+
raw
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def self.equality_value(value)
|
|
143
|
+
return nil if value.to_s == NULL_TOKEN
|
|
144
|
+
|
|
145
|
+
str = value.to_s
|
|
146
|
+
str.include?(",") ? str.split(",") : value
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Portable representation of an operator predicate, applied by the in-memory
|
|
150
|
+
# test relation. Production uses Arel nodes instead (see .predicate_for).
|
|
151
|
+
Predicate = Struct.new(:column, :operator, :value)
|
|
152
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Runs a read-only "show" query for a registered resource by id, rooted on the
|
|
4
|
+
# scoped relation so cross-scope ids are simply not found. Serializes via the
|
|
5
|
+
# resource's serializer.
|
|
6
|
+
class McpToolkit::GetExecutor
|
|
7
|
+
def self.call(resource:, scope_root:, id:, fields: nil)
|
|
8
|
+
new(resource:, scope_root:, id:, fields:).call
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(resource:, scope_root:, id:, fields: nil)
|
|
12
|
+
@resource = resource
|
|
13
|
+
@scope_root = scope_root
|
|
14
|
+
@id = id
|
|
15
|
+
@fields = fields
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
raise McpToolkit::Errors::InvalidParams, "id is required" if id.blank?
|
|
20
|
+
|
|
21
|
+
# Validate the sparse fieldset BEFORE the lookup so a bad `fields` fails fast.
|
|
22
|
+
selection = McpToolkit::FieldSelection.build(resource:, raw: fields)
|
|
23
|
+
record = resource.resolve_relation(scope_root).find_by(id:)
|
|
24
|
+
raise McpToolkit::Errors::InvalidParams, "#{resource.name} not found for id=#{id}" unless record
|
|
25
|
+
|
|
26
|
+
McpToolkit::Serialization.new(resource.serializer, selection).one(record, scope: scope_root)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
attr_reader :resource, :scope_root, :id, :fields
|
|
32
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Runs a read-only, paginated "list" query for a registered resource, rooted on
|
|
4
|
+
# the scoped relation. Supports the standard `ids`, `updated_since`, `limit`,
|
|
5
|
+
# `offset` filters plus the resource's declared per-attribute filters (equality
|
|
6
|
+
# AND operator-based — see McpToolkit::Filtering), and serializes via the
|
|
7
|
+
# resource's serializer, producing the `{ <root> => [...], meta: {...} }` wrapper.
|
|
8
|
+
# An optional `fields` param applies a sparse fieldset to each row (see
|
|
9
|
+
# McpToolkit::FieldSelection).
|
|
10
|
+
class McpToolkit::ListExecutor
|
|
11
|
+
DEFAULT_LIMIT = 25
|
|
12
|
+
MAX_LIMIT = 100
|
|
13
|
+
# Column types whose primary key sorts naturally by `id`. Anything else (e.g. a
|
|
14
|
+
# string/uuid PK) sorts by `created_at` instead.
|
|
15
|
+
NUMERIC_PK_TYPES = %i[integer bigint].freeze
|
|
16
|
+
|
|
17
|
+
def self.call(resource:, scope_root:, params:)
|
|
18
|
+
new(resource:, scope_root:, params:).call
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(resource:, scope_root:, params:)
|
|
22
|
+
@resource = resource
|
|
23
|
+
@scope_root = scope_root
|
|
24
|
+
@params = (params || {}).deep_symbolize_keys
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call
|
|
28
|
+
# Validate the sparse fieldset BEFORE running the query so a bad `fields`
|
|
29
|
+
# fails fast rather than after a needless count + fetch.
|
|
30
|
+
selection = McpToolkit::FieldSelection.build(resource:, raw: params[:fields])
|
|
31
|
+
relation = build_relation
|
|
32
|
+
total_count = relation.count
|
|
33
|
+
rows = paginate(relation).to_a
|
|
34
|
+
|
|
35
|
+
McpToolkit::Serialization.new(resource.serializer, selection).collection(
|
|
36
|
+
rows, scope: scope_root, total_count:, limit:, offset:
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
attr_reader :resource, :scope_root, :params
|
|
43
|
+
|
|
44
|
+
def build_relation
|
|
45
|
+
relation = resource.resolve_relation(scope_root)
|
|
46
|
+
relation = apply_ids(relation)
|
|
47
|
+
relation = apply_updated_since(relation)
|
|
48
|
+
relation = apply_attribute_filters(relation)
|
|
49
|
+
apply_order(relation)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Order by `id` when the primary key is numeric; otherwise by `created_at`
|
|
53
|
+
# (a non-numeric PK does not sort meaningfully).
|
|
54
|
+
def apply_order(relation)
|
|
55
|
+
relation.order(numeric_primary_key? ? :id : :created_at)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def numeric_primary_key?
|
|
59
|
+
model = resource.model
|
|
60
|
+
return true unless model.respond_to?(:columns_hash) && model.respond_to?(:primary_key)
|
|
61
|
+
|
|
62
|
+
pk = model.primary_key
|
|
63
|
+
return true if pk.nil?
|
|
64
|
+
|
|
65
|
+
type = model.columns_hash[pk.to_s]&.type
|
|
66
|
+
type.nil? || NUMERIC_PK_TYPES.include?(type)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Applies per-attribute filters (`filter: { key: value | { op:, value: } | [...] }`).
|
|
70
|
+
# Each request-facing key is resolved against the resource's declared allowlist
|
|
71
|
+
# (`Resource#filterable`) to its backing column, then added as WHERE clause(s) on
|
|
72
|
+
# TOP of the already scoped relation — filtering composes with scoping and can
|
|
73
|
+
# only ever NARROW it, never widen it. Equality and operator-based semantics are
|
|
74
|
+
# delegated to McpToolkit::Filtering.
|
|
75
|
+
#
|
|
76
|
+
# Unknown filter keys are rejected with InvalidParams, so the caller gets
|
|
77
|
+
# actionable feedback instead of a silently-ignored filter.
|
|
78
|
+
def apply_attribute_filters(relation)
|
|
79
|
+
filter = params[:filter]
|
|
80
|
+
return relation if filter.blank?
|
|
81
|
+
|
|
82
|
+
mapping = resource.filterable_columns
|
|
83
|
+
validate_filter_keys!(filter, mapping)
|
|
84
|
+
|
|
85
|
+
filter.each do |request_key, value|
|
|
86
|
+
next if value.nil? || value == ""
|
|
87
|
+
|
|
88
|
+
column = mapping[request_key.to_sym]
|
|
89
|
+
relation = McpToolkit::Filtering.apply(relation, column, value)
|
|
90
|
+
end
|
|
91
|
+
relation
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate_filter_keys!(filter, mapping)
|
|
95
|
+
keys = filter.keys.map(&:to_sym)
|
|
96
|
+
unknown = keys - mapping.keys
|
|
97
|
+
return if unknown.empty?
|
|
98
|
+
|
|
99
|
+
allowed = mapping.keys.sort.join(", ")
|
|
100
|
+
raise McpToolkit::Errors::InvalidParams,
|
|
101
|
+
"unknown filter attribute(s): #{unknown.join(", ")}. " \
|
|
102
|
+
"Filterable attributes for this resource: #{allowed.presence || "(none)"}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def apply_ids(relation)
|
|
106
|
+
return relation if params[:ids].blank?
|
|
107
|
+
|
|
108
|
+
ids = params[:ids].to_s.split(",").map(&:strip).compact_blank
|
|
109
|
+
ids.empty? ? relation : relation.where(id: ids)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def apply_updated_since(relation)
|
|
113
|
+
return relation if params[:updated_since].blank?
|
|
114
|
+
|
|
115
|
+
time = parse_time(params[:updated_since])
|
|
116
|
+
relation.where("#{relation.table_name}.updated_at > ?", time)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def parse_time(value)
|
|
120
|
+
Time.zone.parse(value.to_s) || raise(ArgumentError)
|
|
121
|
+
rescue ArgumentError, TypeError
|
|
122
|
+
raise McpToolkit::Errors::InvalidParams, "updated_since must be an ISO 8601 timestamp"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def paginate(relation)
|
|
126
|
+
relation.offset(offset).limit(limit)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def limit
|
|
130
|
+
raw = params[:limit] || DEFAULT_LIMIT
|
|
131
|
+
[[raw.to_i, 1].max, MAX_LIMIT].min # rubocop:disable Style/ComparableClamp
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def offset
|
|
135
|
+
[params[:offset].to_i, 0].max
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Central registry of read-only resources exposed via the MCP server. Resources
|
|
4
|
+
# are registered at boot (in a `to_prepare` initializer) and consumed by the
|
|
5
|
+
# generic `resources` / `resource_schema` / `get` / `list` tools.
|
|
6
|
+
#
|
|
7
|
+
# Instances are addressable so tests (and, in principle, multiple mounted
|
|
8
|
+
# servers) don't collide; the app-facing convenience is `McpToolkit.registry`,
|
|
9
|
+
# which returns the process-wide instance.
|
|
10
|
+
class McpToolkit::Registry
|
|
11
|
+
class UnknownResource < StandardError; end
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@resources = {}
|
|
15
|
+
@default_required_permissions_scope = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Registry-wide DEFAULT required scope, so a satellite declares its scope ONCE
|
|
19
|
+
# for every resource instead of repeating it per resource:
|
|
20
|
+
#
|
|
21
|
+
# McpToolkit.registry.default_required_permissions_scope "notifications__read"
|
|
22
|
+
#
|
|
23
|
+
# A resource's own `required_permissions_scope` overrides this. Default nil = no
|
|
24
|
+
# scope required unless a resource declares its own. Read with no arg.
|
|
25
|
+
#
|
|
26
|
+
# Declared in the satellite's `configure` block (NOT inside `to_prepare`), so it
|
|
27
|
+
# survives `reset!` and stays set across dev reloads.
|
|
28
|
+
def default_required_permissions_scope(scope = nil)
|
|
29
|
+
@default_required_permissions_scope = scope if scope
|
|
30
|
+
@default_required_permissions_scope
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def register(name, &)
|
|
34
|
+
resource = McpToolkit::Resource.new(name)
|
|
35
|
+
resource.instance_eval(&)
|
|
36
|
+
@resources[name.to_s] = resource
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The scope a token must carry to reach `resource` via the generic tools: the
|
|
40
|
+
# resource's own declared scope, else the registry default, else nil (no check).
|
|
41
|
+
def required_scope_for(resource)
|
|
42
|
+
resource.effective_required_permissions_scope(@default_required_permissions_scope)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def fetch(name)
|
|
46
|
+
find(name) or raise(UnknownResource, McpToolkit::UnknownResourceMessage.new(name, resource_names).build)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def find(name)
|
|
50
|
+
@resources[name.to_s]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def resources
|
|
54
|
+
@resources.values
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def resource_names
|
|
58
|
+
@resources.keys
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Clears registered resources for a dev reload (the satellite re-declares them
|
|
62
|
+
# in `to_prepare`). The `default_required_permissions_scope` is PRESERVED, since
|
|
63
|
+
# it's declared once in `configure` rather than per-reload.
|
|
64
|
+
def reset!
|
|
65
|
+
@resources = {}
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Descriptor for a single read-only resource exposed via the MCP server. Built
|
|
4
|
+
# via `McpToolkit.registry.register`; consumed by the List/Get executors and the
|
|
5
|
+
# resource_schema tool.
|
|
6
|
+
#
|
|
7
|
+
# The `scope_block` is the account-rooting relation: it receives the resolved
|
|
8
|
+
# local scope root (typically an `Account`) and MUST return a relation already
|
|
9
|
+
# scoped so that every row belongs to that root (directly via a foreign key, or
|
|
10
|
+
# transitively through an owning record). This is the single tenancy chokepoint —
|
|
11
|
+
# every `get`/`list` query roots on it.
|
|
12
|
+
#
|
|
13
|
+
# The `serializer` is INJECTABLE per resource: it may be a subclass of the gem's
|
|
14
|
+
# `McpToolkit::Serializer::Base`, or any class satisfying the serializer contract
|
|
15
|
+
# (`serialize_one` / `serialize_collection`) — e.g. an app's existing serializer.
|
|
16
|
+
class McpToolkit::Resource
|
|
17
|
+
class NotConfigured < StandardError; end
|
|
18
|
+
|
|
19
|
+
attr_reader :name
|
|
20
|
+
|
|
21
|
+
def initialize(name)
|
|
22
|
+
@name = name.to_s
|
|
23
|
+
@model = nil
|
|
24
|
+
@serializer = nil
|
|
25
|
+
@scope_block = nil
|
|
26
|
+
@description = nil
|
|
27
|
+
@filterable = {}
|
|
28
|
+
@required_permissions_scope = nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def model(klass = nil)
|
|
32
|
+
@model = klass if klass
|
|
33
|
+
@model
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def serializer(klass = nil)
|
|
37
|
+
@serializer = klass if klass
|
|
38
|
+
@serializer
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def scope(&block)
|
|
42
|
+
@scope_block = block if block
|
|
43
|
+
@scope_block
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def description(text = nil)
|
|
47
|
+
@description = text if text
|
|
48
|
+
@description
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The OAuth-style scope a token MUST carry to reach this resource via the
|
|
52
|
+
# generic tools (e.g. "notifications__read"). Declared explicitly per resource:
|
|
53
|
+
#
|
|
54
|
+
# required_permissions_scope "notifications__read"
|
|
55
|
+
#
|
|
56
|
+
# Default nil = no scope required for this resource (unless the registry sets a
|
|
57
|
+
# default — see Registry#default_required_permissions_scope). Read with no arg.
|
|
58
|
+
def required_permissions_scope(scope = nil)
|
|
59
|
+
@required_permissions_scope = scope if scope
|
|
60
|
+
@required_permissions_scope
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The scope actually enforced for this resource: its own declared scope if set,
|
|
64
|
+
# otherwise the registry-level `default` passed in. nil = no scope required.
|
|
65
|
+
def effective_required_permissions_scope(default = nil)
|
|
66
|
+
@required_permissions_scope || default
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Declares the per-attribute filters this resource accepts on the `list` tool.
|
|
70
|
+
# Each entry maps a REQUEST-FACING filter key to the backing DATABASE COLUMN the
|
|
71
|
+
# WHERE is applied to. The mapping is what lets the consumer-facing key differ
|
|
72
|
+
# from the storage column (e.g. exposing a synced foreign key under its public
|
|
73
|
+
# name):
|
|
74
|
+
#
|
|
75
|
+
# filterable booking_id: :synced_booking_id
|
|
76
|
+
#
|
|
77
|
+
# A declared key accepts both a bare equality value AND operator-based
|
|
78
|
+
# conditions (`{ op:, value: }` or an array of them, ANDed) — see
|
|
79
|
+
# McpToolkit::Filtering for the supported operators per column type.
|
|
80
|
+
#
|
|
81
|
+
# Unmapped/unknown keys are rejected by the list executor, never silently
|
|
82
|
+
# dropped, so a typo surfaces as actionable feedback.
|
|
83
|
+
def filterable(mapping = nil)
|
|
84
|
+
return @filterable if mapping.nil?
|
|
85
|
+
|
|
86
|
+
mapping.each do |request_key, column|
|
|
87
|
+
@filterable[request_key.to_sym] = column.to_sym
|
|
88
|
+
end
|
|
89
|
+
@filterable
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Request-facing filter keys (symbols, sorted) this resource can be filtered
|
|
93
|
+
# by. Surfaced via the `resource_schema` tool.
|
|
94
|
+
def filterable_keys
|
|
95
|
+
@filterable.keys.sort
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Request-facing filter key (symbol) => backing column (symbol). Consumed by
|
|
99
|
+
# the list executor to build the WHERE clause.
|
|
100
|
+
def filterable_columns
|
|
101
|
+
@filterable
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The account-scoped relation for this resource. Raises if misconfigured so a
|
|
105
|
+
# registry mistake fails loudly rather than leaking an unscoped query.
|
|
106
|
+
def resolve_relation(scope_root)
|
|
107
|
+
raise NotConfigured, "resource #{name.inspect} has no scope block" unless scope
|
|
108
|
+
raise NotConfigured, "resource #{name.inspect} has no model" unless model
|
|
109
|
+
raise NotConfigured, "resource #{name.inspect} has no serializer" unless serializer
|
|
110
|
+
|
|
111
|
+
scope.call(scope_root)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Serialized attribute names (the response shape), read off the serializer's
|
|
115
|
+
# declared attributes. Requires a serializer that exposes `declared_attributes`
|
|
116
|
+
# (the gem's base does); resource_schema degrades gracefully otherwise.
|
|
117
|
+
def attribute_names
|
|
118
|
+
return [] unless serializer.respond_to?(:declared_attributes)
|
|
119
|
+
|
|
120
|
+
serializer.declared_attributes.map(&:to_sym)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Association descriptors (the `links` shape) read off the serializer.
|
|
124
|
+
def association_descriptors
|
|
125
|
+
return [] unless serializer.respond_to?(:declared_associations)
|
|
126
|
+
|
|
127
|
+
serializer.declared_associations
|
|
128
|
+
end
|
|
129
|
+
end
|