mcp_toolkit 0.4.0 → 0.5.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 +4 -4
- data/CHANGELOG.md +231 -0
- data/config/routes.rb +1 -1
- data/lib/mcp_toolkit/authority/controller_methods.rb +15 -0
- data/lib/mcp_toolkit/authority/registry_tool_provider.rb +9 -10
- data/lib/mcp_toolkit/authority/single_tool_provider.rb +25 -0
- data/lib/mcp_toolkit/authority/tools/base.rb +31 -3
- data/lib/mcp_toolkit/authority/tools/get.rb +2 -1
- data/lib/mcp_toolkit/authority/tools/list.rb +49 -1
- data/lib/mcp_toolkit/authority/tools/resource_schema.rb +10 -2
- data/lib/mcp_toolkit/authority/tools/resources.rb +26 -4
- data/lib/mcp_toolkit/configuration.rb +161 -7
- data/lib/mcp_toolkit/dispatcher.rb +17 -3
- data/lib/mcp_toolkit/engine.rb +5 -2
- data/lib/mcp_toolkit/engine_controllers.rb +11 -3
- data/lib/mcp_toolkit/filtering.rb +168 -23
- data/lib/mcp_toolkit/gateway/aggregator.rb +25 -5
- data/lib/mcp_toolkit/list_executor.rb +48 -4
- data/lib/mcp_toolkit/resource.rb +55 -6
- data/lib/mcp_toolkit/resource_schema.rb +125 -16
- data/lib/mcp_toolkit/serializer/association_descriptor.rb +11 -0
- data/lib/mcp_toolkit/serializer/target_ref.rb +7 -0
- data/lib/mcp_toolkit/session.rb +2 -2
- data/lib/mcp_toolkit/tool_reference_rewriter.rb +33 -0
- data/lib/mcp_toolkit/tools/authority_base.rb +23 -2
- data/lib/mcp_toolkit/tools/base.rb +23 -3
- data/lib/mcp_toolkit/tools/get.rb +1 -1
- data/lib/mcp_toolkit/tools/list.rb +27 -9
- data/lib/mcp_toolkit/tools/resource_schema.rb +12 -3
- data/lib/mcp_toolkit/tools/resources.rb +30 -7
- data/lib/mcp_toolkit/usage_metering/recorder.rb +28 -2
- data/lib/mcp_toolkit/version.rb +1 -1
- metadata +5 -1
|
@@ -66,10 +66,20 @@ class McpToolkit::ListExecutor
|
|
|
66
66
|
relation
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
-
# Order by `id` when the primary key is numeric; otherwise
|
|
70
|
-
#
|
|
69
|
+
# Order by `id` when the primary key is numeric; otherwise per
|
|
70
|
+
# `config.non_numeric_pk_order`: `created_at` with the primary key as a
|
|
71
|
+
# tiebreaker (default — rows bulk-inserted in one transaction share a
|
|
72
|
+
# `created_at`, and without a total order offset pagination could duplicate
|
|
73
|
+
# or skip rows), or the primary key alone for a host preserving a pre-gem
|
|
74
|
+
# order-by-id contract. `numeric_primary_key?` returning false guarantees the
|
|
75
|
+
# model exposes a non-nil primary key, so it can be read directly here.
|
|
71
76
|
def apply_order(relation)
|
|
72
|
-
relation.order(
|
|
77
|
+
return relation.order(:id) if numeric_primary_key?
|
|
78
|
+
|
|
79
|
+
pk = resource.model.primary_key.to_sym
|
|
80
|
+
return relation.order(pk) if McpToolkit.config.non_numeric_pk_order == :primary_key
|
|
81
|
+
|
|
82
|
+
relation.order(:created_at, pk)
|
|
73
83
|
end
|
|
74
84
|
|
|
75
85
|
def numeric_primary_key?
|
|
@@ -98,9 +108,10 @@ class McpToolkit::ListExecutor
|
|
|
98
108
|
|
|
99
109
|
mapping = resource.filterable_columns
|
|
100
110
|
validate_filter_keys!(filter, mapping)
|
|
111
|
+
validate_filter_companions!(filter)
|
|
101
112
|
|
|
102
113
|
filter.each do |request_key, value|
|
|
103
|
-
next if
|
|
114
|
+
next if skipped_filter_value?(value)
|
|
104
115
|
|
|
105
116
|
column = mapping[request_key.to_sym]
|
|
106
117
|
relation = McpToolkit::Filtering.apply(relation, column, value)
|
|
@@ -108,6 +119,35 @@ class McpToolkit::ListExecutor
|
|
|
108
119
|
relation
|
|
109
120
|
end
|
|
110
121
|
|
|
122
|
+
# Under :tokenized semantics an empty string means "no filter" (a JSON null
|
|
123
|
+
# still flows through as an IS NULL filter, like the "null" token — see
|
|
124
|
+
# McpToolkit::Filtering); under :literal every value reaches the WHERE clause
|
|
125
|
+
# verbatim.
|
|
126
|
+
def skipped_filter_value?(value)
|
|
127
|
+
value == "" && McpToolkit.config.bare_filter_value_semantics != :literal
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# A filter key may declare a companion key it cannot be used without (e.g. a
|
|
131
|
+
# polymorphic foreign key is type-ambiguous without its `*_type`) — see
|
|
132
|
+
# Resource#filter_requirements. Rejected up front rather than producing a
|
|
133
|
+
# subtly wrong WHERE. A key whose value the apply loop will SKIP counts as
|
|
134
|
+
# not provided — a skipped ("" under :tokenized) companion must not satisfy
|
|
135
|
+
# the requirement, or the FK would be applied alone: exactly the
|
|
136
|
+
# type-ambiguous WHERE the requirement exists to prevent.
|
|
137
|
+
def validate_filter_companions!(filter)
|
|
138
|
+
requirements = resource.filter_requirements
|
|
139
|
+
return if requirements.empty?
|
|
140
|
+
|
|
141
|
+
provided = filter.reject { |_key, value| skipped_filter_value?(value) }.keys.map(&:to_sym)
|
|
142
|
+
requirements.each do |key, required|
|
|
143
|
+
next unless provided.include?(key)
|
|
144
|
+
next if provided.include?(required)
|
|
145
|
+
|
|
146
|
+
raise McpToolkit::Errors::InvalidParams,
|
|
147
|
+
"filter attribute #{key} requires #{required} to also be provided"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
111
151
|
def validate_filter_keys!(filter, mapping)
|
|
112
152
|
keys = filter.keys.map(&:to_sym)
|
|
113
153
|
unknown = keys - mapping.keys
|
|
@@ -123,6 +163,10 @@ class McpToolkit::ListExecutor
|
|
|
123
163
|
return relation if params[:ids].blank?
|
|
124
164
|
|
|
125
165
|
ids = params[:ids].to_s.split(",").map(&:strip).compact_blank
|
|
166
|
+
# Bound the IN-set the same way the per-attribute filters are bounded — the
|
|
167
|
+
# top-level `ids` param builds `WHERE id IN (...)` on its own path, so it
|
|
168
|
+
# must honor config.max_filter_values too (else it's an unbounded IN clause).
|
|
169
|
+
McpToolkit::Filtering.enforce_filter_limit!(ids.length, McpToolkit.config)
|
|
126
170
|
ids.empty? ? relation : relation.where(id: ids)
|
|
127
171
|
end
|
|
128
172
|
|
data/lib/mcp_toolkit/resource.rb
CHANGED
|
@@ -42,6 +42,8 @@ class McpToolkit::Resource
|
|
|
42
42
|
@superusers_only = false
|
|
43
43
|
@filterable = {}
|
|
44
44
|
@filterable_source = nil
|
|
45
|
+
@filter_requirements = {}
|
|
46
|
+
@filter_requirements_source = nil
|
|
45
47
|
@custom_filters = {}
|
|
46
48
|
@required_permissions_scope = nil
|
|
47
49
|
@extras = {}
|
|
@@ -194,6 +196,35 @@ class McpToolkit::Resource
|
|
|
194
196
|
filterable_columns.keys.sort
|
|
195
197
|
end
|
|
196
198
|
|
|
199
|
+
# Declares companion-key requirements for filter keys: a request-facing key
|
|
200
|
+
# that is only valid when another key is passed alongside it (the canonical
|
|
201
|
+
# case is a polymorphic foreign key, type-ambiguous without its `*_type`):
|
|
202
|
+
#
|
|
203
|
+
# filter_requirements created_by_id: :created_by_type
|
|
204
|
+
#
|
|
205
|
+
# The list executor rejects a filter using the key without its companion, and
|
|
206
|
+
# `resource_schema` surfaces the requirement (`relationships[].filter.requires`)
|
|
207
|
+
# so a client can discover it. The companion key MUST itself be declared
|
|
208
|
+
# `filterable` — otherwise the requirement is unsatisfiable (the executor
|
|
209
|
+
# rejects the companion as an unknown key) while the schema still advertises
|
|
210
|
+
# it. Like `filterable`, accepts a Hash (merged now) OR a callable returning
|
|
211
|
+
# one (resolved lazily on first successful read, then memoized) so a host can
|
|
212
|
+
# derive the map without touching the DB at boot. Read with no arg.
|
|
213
|
+
def filter_requirements(mapping = nil, &block)
|
|
214
|
+
source = block || mapping
|
|
215
|
+
if source.nil?
|
|
216
|
+
resolve_filter_requirements_source!
|
|
217
|
+
return @filter_requirements
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
if source.respond_to?(:call)
|
|
221
|
+
@filter_requirements_source = source
|
|
222
|
+
else
|
|
223
|
+
merge_filter_requirements!(source)
|
|
224
|
+
end
|
|
225
|
+
self
|
|
226
|
+
end
|
|
227
|
+
|
|
197
228
|
# Request-facing filter key (symbol) => backing column (symbol). Consumed by
|
|
198
229
|
# the list executor to build the WHERE clause.
|
|
199
230
|
def filterable_columns
|
|
@@ -229,19 +260,37 @@ class McpToolkit::Resource
|
|
|
229
260
|
|
|
230
261
|
private
|
|
231
262
|
|
|
232
|
-
# Resolves a lazily-provided filterable source (a callable)
|
|
233
|
-
#
|
|
234
|
-
# reads are pure Hash access. This is what keeps a DB-derived map (e.g.
|
|
235
|
-
# `Model.column_names`) out of registration/boot time.
|
|
263
|
+
# Resolves a lazily-provided filterable source (a callable) on the first
|
|
264
|
+
# SUCCESSFUL `filterable_columns` / `filterable_keys` read — then drops it, so
|
|
265
|
+
# later reads are pure Hash access. This is what keeps a DB-derived map (e.g.
|
|
266
|
+
# `Model.column_names`) out of registration/boot time. The source is cleared
|
|
267
|
+
# only AFTER it returns: a raising callable (a transient DB hiccup) stays
|
|
268
|
+
# registered and is retried on the next read, instead of permanently and
|
|
269
|
+
# silently resolving the allowlist to `{}`.
|
|
236
270
|
def resolve_filterable_source!
|
|
237
271
|
return unless @filterable_source
|
|
238
272
|
|
|
239
|
-
|
|
273
|
+
resolved = @filterable_source.call || {}
|
|
240
274
|
@filterable_source = nil
|
|
241
|
-
merge_filterable!(
|
|
275
|
+
merge_filterable!(resolved)
|
|
242
276
|
end
|
|
243
277
|
|
|
244
278
|
def merge_filterable!(mapping)
|
|
245
279
|
mapping.each { |request_key, column| @filterable[request_key.to_sym] = column.to_sym }
|
|
246
280
|
end
|
|
281
|
+
|
|
282
|
+
# Same lazy-resolution contract as the filterable source: resolved on the
|
|
283
|
+
# first successful read, cleared only after the callable returns so a
|
|
284
|
+
# transient failure is retried rather than silently dropping the map.
|
|
285
|
+
def resolve_filter_requirements_source!
|
|
286
|
+
return unless @filter_requirements_source
|
|
287
|
+
|
|
288
|
+
resolved = @filter_requirements_source.call || {}
|
|
289
|
+
@filter_requirements_source = nil
|
|
290
|
+
merge_filter_requirements!(resolved)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def merge_filter_requirements!(mapping)
|
|
294
|
+
mapping.each { |key, required| @filter_requirements[key.to_sym] = required.to_sym }
|
|
295
|
+
end
|
|
247
296
|
end
|
|
@@ -41,9 +41,12 @@ class McpToolkit::ResourceSchema
|
|
|
41
41
|
note: resource.note,
|
|
42
42
|
attributes:,
|
|
43
43
|
relationships:,
|
|
44
|
+
resource_filters:,
|
|
44
45
|
standard_filters: STANDARD_FILTERS,
|
|
46
|
+
sparse_fieldsets: true,
|
|
47
|
+
filter_examples:,
|
|
45
48
|
filters:
|
|
46
|
-
}
|
|
49
|
+
}.compact
|
|
47
50
|
end
|
|
48
51
|
|
|
49
52
|
private
|
|
@@ -51,7 +54,7 @@ class McpToolkit::ResourceSchema
|
|
|
51
54
|
attr_reader :resource, :model, :registry
|
|
52
55
|
|
|
53
56
|
def attributes
|
|
54
|
-
resource.attribute_names.map { |name| attribute_schema(name) }
|
|
57
|
+
@attributes ||= resource.attribute_names.map { |name| attribute_schema(name) }
|
|
55
58
|
end
|
|
56
59
|
|
|
57
60
|
def attribute_schema(name)
|
|
@@ -62,18 +65,18 @@ class McpToolkit::ResourceSchema
|
|
|
62
65
|
format: type ? TYPE_FORMATS[type] : nil,
|
|
63
66
|
filterable: filterable_column_for(name).present?,
|
|
64
67
|
operators: operators_for(name)
|
|
65
|
-
}
|
|
68
|
+
}
|
|
66
69
|
end
|
|
67
70
|
|
|
68
71
|
# The filter operators an attribute accepts, derived from the backing column's
|
|
69
|
-
# type via McpToolkit::Filtering
|
|
70
|
-
# attribute (or one
|
|
71
|
-
#
|
|
72
|
+
# type via McpToolkit::Filtering.operators_for. `[]` for a non-filterable
|
|
73
|
+
# attribute (or one with no backing column) — self-describing so a client
|
|
74
|
+
# knows exactly which `{ op:, value: }` conditions `list` will accept.
|
|
72
75
|
def operators_for(attribute_name)
|
|
73
76
|
pair = filterable_column_for(attribute_name)
|
|
74
77
|
return [] unless pair
|
|
75
78
|
|
|
76
|
-
McpToolkit::Filtering
|
|
79
|
+
McpToolkit::Filtering.operators_for(column_type(pair.last))
|
|
77
80
|
end
|
|
78
81
|
|
|
79
82
|
# Per-attribute equality filters this resource accepts on the `list` tool's
|
|
@@ -92,6 +95,88 @@ class McpToolkit::ResourceSchema
|
|
|
92
95
|
end
|
|
93
96
|
end
|
|
94
97
|
|
|
98
|
+
# The resource's custom filters (Resource#filter) — resource-specific filters
|
|
99
|
+
# passed as TOP-LEVEL params of the `list` tool (NOT inside `filter`), each
|
|
100
|
+
# applied by a host-supplied block. Surfaced with name/type/description so a
|
|
101
|
+
# client can discover them; `[]` for a resource that declares none.
|
|
102
|
+
# Entries keep nil type/description keys (rather than compacting) — the
|
|
103
|
+
# pre-gem contract emitted them, and an always-present shape is easier for a
|
|
104
|
+
# client to consume.
|
|
105
|
+
def resource_filters
|
|
106
|
+
resource.custom_filters.each_value.map do |custom_filter|
|
|
107
|
+
{
|
|
108
|
+
name: custom_filter.name.to_s,
|
|
109
|
+
type: custom_filter.type&.to_s,
|
|
110
|
+
description: custom_filter.description
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Ready-to-use `filter` payload examples built from this resource's own
|
|
116
|
+
# filterable attributes and relationships, so a client can copy a working
|
|
117
|
+
# shape instead of deriving it from the operator lists.
|
|
118
|
+
def filter_examples
|
|
119
|
+
[equality_example, comparison_example, range_example, relationship_example].compact
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def equality_example
|
|
123
|
+
attribute = example_attributes.find { |candidate| candidate[:type] == "string" } || example_attributes.first
|
|
124
|
+
return unless attribute
|
|
125
|
+
|
|
126
|
+
{ attribute[:name] => sample_value(attribute[:type]) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def comparison_example
|
|
130
|
+
attribute = comparison_attribute
|
|
131
|
+
return unless attribute
|
|
132
|
+
|
|
133
|
+
{ attribute[:name] => { op: "gt", value: sample_value(attribute[:type]) } }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def range_example
|
|
137
|
+
attribute = comparison_attribute
|
|
138
|
+
return unless attribute
|
|
139
|
+
|
|
140
|
+
{
|
|
141
|
+
attribute[:name] => [
|
|
142
|
+
{ op: "gteq", value: sample_value(attribute[:type]) },
|
|
143
|
+
{ op: "lt", value: sample_value(attribute[:type]) }
|
|
144
|
+
]
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def relationship_example
|
|
149
|
+
relationship = relationships.find { |candidate| candidate[:filter] }
|
|
150
|
+
return unless relationship
|
|
151
|
+
|
|
152
|
+
example = { relationship[:filter][:keys].first => 1 }
|
|
153
|
+
example[relationship[:filter][:requires]] = "User" if relationship[:filter][:requires]
|
|
154
|
+
example
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def filterable_attributes
|
|
158
|
+
attributes.select { |attribute| attribute[:filterable] }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# `id` is filterable but uninteresting as an example (use the `ids` filter for that).
|
|
162
|
+
def example_attributes
|
|
163
|
+
filterable_attributes.reject { |attribute| attribute[:name] == :id }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def comparison_attribute
|
|
167
|
+
example_attributes.find { |attribute| attribute[:operators].include?("gt") }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def sample_value(type)
|
|
171
|
+
case type.to_s
|
|
172
|
+
when "integer" then 1
|
|
173
|
+
when "decimal", "float" then "100.0"
|
|
174
|
+
when "boolean" then true
|
|
175
|
+
when "datetime", "date" then "2026-01-01T00:00:00Z"
|
|
176
|
+
else "..."
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
95
180
|
# Backing column for a serialized attribute that is also a filter key, if any.
|
|
96
181
|
# A filter key may be a public alias (e.g. booking_id -> synced_booking_id) so
|
|
97
182
|
# we match on either the request key or the column.
|
|
@@ -102,22 +187,46 @@ class McpToolkit::ResourceSchema
|
|
|
102
187
|
end
|
|
103
188
|
|
|
104
189
|
def relationships
|
|
105
|
-
resource.association_descriptors.map { |association| relationship_schema(association) }
|
|
190
|
+
@relationships ||= resource.association_descriptors.map { |association| relationship_schema(association) }
|
|
106
191
|
end
|
|
107
192
|
|
|
108
|
-
# One relationship entry
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
# link is discoverably the
|
|
112
|
-
#
|
|
113
|
-
#
|
|
193
|
+
# One relationship entry: the link key/kind/polymorphic flag, the registered
|
|
194
|
+
# resource the link resolves to — emitted BOTH as `resource` (nullable) and,
|
|
195
|
+
# when resolved, as `target_resource` (so e.g. a
|
|
196
|
+
# `scheduled_notifications.notification` link is discoverably the
|
|
197
|
+
# `notifications` resource rather than a name to guess) — and, when the
|
|
198
|
+
# link's foreign key is filterable, a `filter` block telling a client HOW to
|
|
199
|
+
# filter by the relationship (see #relationship_filter).
|
|
114
200
|
def relationship_schema(association)
|
|
115
201
|
target = target_resource_for(association)
|
|
116
|
-
{
|
|
202
|
+
schema = {
|
|
117
203
|
name: association.links_key,
|
|
118
204
|
kind: association.type.to_s,
|
|
119
205
|
polymorphic: association.polymorphic || false,
|
|
120
|
-
|
|
206
|
+
resource: target&.name,
|
|
207
|
+
filter: relationship_filter(association.links_key)
|
|
208
|
+
}
|
|
209
|
+
schema[:target_resource] = target.name if target
|
|
210
|
+
schema
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# How to filter by a relationship, when its foreign key is in the filter
|
|
214
|
+
# allowlist: the accepted request keys (the FK, plus the bare link name when
|
|
215
|
+
# aliased), the backing column's type, its operators, and — for a key that
|
|
216
|
+
# cannot be used alone (e.g. a polymorphic FK needing its `*_type`) — the
|
|
217
|
+
# companion key it `requires` (see Resource#filter_requirements).
|
|
218
|
+
def relationship_filter(name)
|
|
219
|
+
id_key = :"#{name}_id"
|
|
220
|
+
column = resource.filterable_columns[id_key]
|
|
221
|
+
return nil unless column
|
|
222
|
+
|
|
223
|
+
keys = [id_key]
|
|
224
|
+
keys << name.to_sym if resource.filterable_columns.key?(name.to_sym)
|
|
225
|
+
{
|
|
226
|
+
keys:,
|
|
227
|
+
type: column_type(column).to_s,
|
|
228
|
+
operators: operators_for(id_key),
|
|
229
|
+
requires: resource.filter_requirements[id_key]
|
|
121
230
|
}.compact
|
|
122
231
|
end
|
|
123
232
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The association shape the gem reads off a serializer's
|
|
4
|
+
# `declared_associations` (ResourceSchema's relationship entries,
|
|
5
|
+
# FieldSelection's valid `fields` names). A host adapting its OWN serializer
|
|
6
|
+
# framework builds these rather than re-deriving the duck-type by hand:
|
|
7
|
+
# `links_key` / `type` / `polymorphic` / `name`, plus an optional `serializer`
|
|
8
|
+
# responding to `model_class` (see TargetRef) so the target resource resolves.
|
|
9
|
+
McpToolkit::Serializer::AssociationDescriptor = Struct.new(
|
|
10
|
+
:name, :type, :polymorphic, :links_key, :serializer, keyword_init: true
|
|
11
|
+
)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Minimal object satisfying the gem's `association.serializer.model_class`
|
|
4
|
+
# probe (ResourceSchema's target-resource resolution): carries the model an
|
|
5
|
+
# association resolves to. Pair with AssociationDescriptor when adapting a
|
|
6
|
+
# host serializer framework.
|
|
7
|
+
McpToolkit::Serializer::TargetRef = Struct.new(:model_class)
|
data/lib/mcp_toolkit/session.rb
CHANGED
|
@@ -28,9 +28,9 @@ class McpToolkit::Session
|
|
|
28
28
|
stored = config.cache_store.read(cache_key(id))
|
|
29
29
|
return nil unless stored
|
|
30
30
|
|
|
31
|
-
# Sliding expiry: bump TTL on every successful lookup
|
|
31
|
+
# Sliding expiry: bump TTL on every successful lookup, re-writing the row
|
|
32
|
+
# untouched.
|
|
32
33
|
config.cache_store.write(cache_key(id), stored, expires_in: config.session_ttl)
|
|
33
|
-
# `data` defaults to {} for legacy rows written before the payload existed.
|
|
34
34
|
new(id:, data: stored[:data] || {})
|
|
35
35
|
end
|
|
36
36
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Rewrites backticked generic-tool references ("use the `resources` tool") inside
|
|
4
|
+
# tool prose so they always name the tools exactly as they appear in the serving
|
|
5
|
+
# server's `tools/list`. Two serving paths need this: the authority's own generic
|
|
6
|
+
# tools when the host configures a `generic_tool_name_prefix` (a client would
|
|
7
|
+
# otherwise be pointed at an unprefixed tool that does not exist), and the
|
|
8
|
+
# gateway's aggregated upstream tools, whose names are re-keyed into the
|
|
9
|
+
# `<app>__<tool>` namespace while their prose would otherwise keep naming the
|
|
10
|
+
# upstream's bare tools.
|
|
11
|
+
#
|
|
12
|
+
# Only EXACT backticked base names are rewritten; other backticked terms
|
|
13
|
+
# (`resource`, `filter`, `target_resource`, ...) never match.
|
|
14
|
+
module McpToolkit::ToolReferenceRewriter
|
|
15
|
+
GENERIC_TOOL_REFERENCES = /`(resource_schema|resources|list|get)`/
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Rewrites `node` (a String, or a Hash/Array structure such as a tool
|
|
20
|
+
# definition or input schema, walked recursively) with `name_prefix` applied to
|
|
21
|
+
# every backticked generic-tool reference. Non-string leaves pass through
|
|
22
|
+
# untouched; an empty prefix returns the node verbatim.
|
|
23
|
+
def rewrite(node, name_prefix)
|
|
24
|
+
return node if name_prefix.to_s.empty?
|
|
25
|
+
|
|
26
|
+
case node
|
|
27
|
+
when String then node.gsub(GENERIC_TOOL_REFERENCES) { "`#{name_prefix}#{Regexp.last_match(1)}`" }
|
|
28
|
+
when Hash then node.transform_values { |value| rewrite(value, name_prefix) }
|
|
29
|
+
when Array then node.map { |value| rewrite(value, name_prefix) }
|
|
30
|
+
else node
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -105,6 +105,15 @@ class McpToolkit::Tools::AuthorityBase
|
|
|
105
105
|
raise McpToolkit::Protocol::InvalidRequest, "#{resource.name} is restricted to superuser (user-scoped) MCP tokens"
|
|
106
106
|
end
|
|
107
107
|
|
|
108
|
+
# ArgumentErrors Ruby itself raises while binding the request arguments to the
|
|
109
|
+
# subclass's `#call` signature: a missing/unknown keyword or wrong arity. Their
|
|
110
|
+
# messages carry only keyword names (the tool's own declared params) and counts,
|
|
111
|
+
# so surfacing them tells a client how to fix the call. Every OTHER ArgumentError
|
|
112
|
+
# is raised from inside the tool's business logic (e.g. `Integer("x")`) and its
|
|
113
|
+
# message may carry SQL, internal class names, or a value — so it is sanitized
|
|
114
|
+
# like any unexpected error rather than relayed verbatim.
|
|
115
|
+
ARGUMENT_BINDING_ERROR_MESSAGE = /\A(missing keyword|unknown keyword|wrong number of arguments)/
|
|
116
|
+
|
|
108
117
|
# Runs the tool's business logic (the subclass's `#call`) with error mapping.
|
|
109
118
|
# Arrives with symbol-keyed arguments from the dispatcher.
|
|
110
119
|
def execute(**arguments)
|
|
@@ -114,9 +123,11 @@ class McpToolkit::Tools::AuthorityBase
|
|
|
114
123
|
# (e.g. InvalidParams); let it bubble untouched so the client sees it.
|
|
115
124
|
raise
|
|
116
125
|
rescue ArgumentError => e
|
|
117
|
-
raise McpToolkit::Protocol::InvalidParams, e.message
|
|
126
|
+
raise McpToolkit::Protocol::InvalidParams, e.message if ARGUMENT_BINDING_ERROR_MESSAGE.match?(e.message)
|
|
127
|
+
|
|
128
|
+
raise sanitized_internal_error(e)
|
|
118
129
|
rescue StandardError => e
|
|
119
|
-
raise
|
|
130
|
+
raise sanitized_internal_error(e)
|
|
120
131
|
end
|
|
121
132
|
|
|
122
133
|
# The subclass implements its business logic here, receiving the tool arguments
|
|
@@ -124,4 +135,14 @@ class McpToolkit::Tools::AuthorityBase
|
|
|
124
135
|
def call(**_arguments)
|
|
125
136
|
raise NotImplementedError, "#{self.class} must implement #call"
|
|
126
137
|
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
# An UNEXPECTED error's message may carry SQL, internal class names, or a
|
|
142
|
+
# hostname — it must not reach the caller (the dispatcher relays a
|
|
143
|
+
# Protocol::Error's message verbatim). Log the detail; return a generic error.
|
|
144
|
+
def sanitized_internal_error(error)
|
|
145
|
+
McpToolkit.config.logger&.error("MCP tool #{self.class} error: #{error.message}\n#{error.backtrace&.join("\n")}")
|
|
146
|
+
McpToolkit::Protocol::InternalError.new("Internal error")
|
|
147
|
+
end
|
|
127
148
|
end
|
|
@@ -26,7 +26,7 @@ class McpToolkit::Tools::Base < MCP::Tool
|
|
|
26
26
|
# `required_scope` is the explicitly-declared scope a token must carry (the
|
|
27
27
|
# caller resolves it from the resource — see Registry#required_scope_for).
|
|
28
28
|
# Empty/nil => no scope check (authorized_for_scope? treats "" as a pass).
|
|
29
|
-
def self.with_account(server_context, account_id: nil, required_scope: nil)
|
|
29
|
+
def self.with_account(server_context, account_id: nil, required_scope: nil, resource: nil)
|
|
30
30
|
config = config_from(server_context)
|
|
31
31
|
context = McpToolkit::Auth::Authenticator.call(
|
|
32
32
|
token: server_context[:bearer_token],
|
|
@@ -40,6 +40,9 @@ class McpToolkit::Tools::Base < MCP::Tool
|
|
|
40
40
|
return error_response("Unauthorized: token lacks the #{required_scope.inspect} scope")
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
superuser_refusal = superuser_only_refusal(resource, context.introspection)
|
|
44
|
+
return superuser_refusal if superuser_refusal
|
|
45
|
+
|
|
43
46
|
text_response(yield(context.scope_root))
|
|
44
47
|
rescue McpToolkit::Errors::Unauthorized => e
|
|
45
48
|
error_response("Unauthorized: #{e.message}")
|
|
@@ -52,7 +55,7 @@ class McpToolkit::Tools::Base < MCP::Tool
|
|
|
52
55
|
# which reveal shape, not tenant data, so a superuser shouldn't have to pin an
|
|
53
56
|
# account just to discover what exists. Empty/nil `required_scope` => no scope
|
|
54
57
|
# check.
|
|
55
|
-
def self.with_authentication(server_context, required_scope: nil)
|
|
58
|
+
def self.with_authentication(server_context, required_scope: nil, resource: nil)
|
|
56
59
|
config = config_from(server_context)
|
|
57
60
|
introspection = McpToolkit::Auth::Introspection.call(server_context[:bearer_token], config:)
|
|
58
61
|
return error_response("Unauthorized: invalid or expired token") unless introspection.valid?
|
|
@@ -61,11 +64,28 @@ class McpToolkit::Tools::Base < MCP::Tool
|
|
|
61
64
|
return error_response("Unauthorized: token lacks the #{required_scope.inspect} scope")
|
|
62
65
|
end
|
|
63
66
|
|
|
64
|
-
|
|
67
|
+
superuser_refusal = superuser_only_refusal(resource, introspection)
|
|
68
|
+
return superuser_refusal if superuser_refusal
|
|
69
|
+
|
|
70
|
+
# Yields the introspection so a discovery tool (`resources`) can HIDE
|
|
71
|
+
# superuser-only resources from a non-superuser caller (get/list/resource_schema
|
|
72
|
+
# instead pass `resource:` above to REFUSE a specific one).
|
|
73
|
+
text_response(yield(introspection))
|
|
65
74
|
rescue McpToolkit::Errors::InvalidParams => e
|
|
66
75
|
error_response("Invalid request: #{e.message}")
|
|
67
76
|
end
|
|
68
77
|
|
|
78
|
+
# Refuses a superuser-only resource for a non-superuser caller (get / list /
|
|
79
|
+
# resource_schema); nil = allowed. Mirrors the authority path's
|
|
80
|
+
# `ensure_resource_accessible!`. `resources` HIDES such resources instead of
|
|
81
|
+
# refusing — it filters on `introspection.superuser?` directly.
|
|
82
|
+
def self.superuser_only_refusal(resource, introspection)
|
|
83
|
+
return nil unless resource&.superusers_only?
|
|
84
|
+
return nil if introspection.superuser?
|
|
85
|
+
|
|
86
|
+
error_response("Unauthorized: #{resource.name} is restricted to superuser (user-scoped) MCP tokens")
|
|
87
|
+
end
|
|
88
|
+
|
|
69
89
|
def self.text_response(payload)
|
|
70
90
|
text = payload.is_a?(String) ? payload : JSON.generate(payload)
|
|
71
91
|
MCP::Tool::Response.new([{ type: "text", text: }])
|
|
@@ -48,7 +48,7 @@ class McpToolkit::Tools::Get < McpToolkit::Tools::Base
|
|
|
48
48
|
# the scope check (and so an unknown resource is a clean tool error).
|
|
49
49
|
descriptor = resolve_descriptor(resource, config)
|
|
50
50
|
required_scope = config.registry.required_scope_for(descriptor)
|
|
51
|
-
with_account(server_context, account_id:, required_scope:) do |scope_root|
|
|
51
|
+
with_account(server_context, account_id:, required_scope:, resource: descriptor) do |scope_root|
|
|
52
52
|
McpToolkit::GetExecutor.call(resource: descriptor, scope_root:, id:, fields:)
|
|
53
53
|
end
|
|
54
54
|
rescue McpToolkit::Errors::InvalidParams => e
|
|
@@ -15,11 +15,26 @@ class McpToolkit::Tools::List < McpToolkit::Tools::Base
|
|
|
15
15
|
- limit: page size (default 25, max 100)
|
|
16
16
|
- offset: pagination offset (default 0)
|
|
17
17
|
|
|
18
|
-
Per-attribute
|
|
19
|
-
- filter: an object of { <key>: <value> }
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
Per-attribute filters:
|
|
19
|
+
- filter: an object of { <key>: <value> } filters, applied ON TOP of the account scope
|
|
20
|
+
(they can only narrow, never widen). Each resource advertises its available filter keys
|
|
21
|
+
and operators via `resource_schema`. Unknown keys are rejected.
|
|
22
|
+
- A bare value matches by equality. A comma-separated string or an array of scalars
|
|
23
|
+
matches ANY of the values (IN), e.g. { "status": "booked,canceled" } or
|
|
24
|
+
{ "status": ["booked", "canceled"] }. The string "null" (or a JSON null) matches
|
|
25
|
+
records where the value is NULL.
|
|
26
|
+
- An operator condition is an object { "op": <operator>, "value": <value> }, e.g.
|
|
27
|
+
{ "price": { "op": "gteq", "value": 100 } }. An array of conditions ANDs them into a
|
|
28
|
+
range: { "price": [{ "op": "gteq", "value": 100 }, { "op": "lt", "value": 200 }] }.
|
|
29
|
+
Each attribute's supported operators are listed in `resource_schema`.
|
|
30
|
+
- Some filter keys require a companion key (e.g. a polymorphic id and its type) —
|
|
31
|
+
`resource_schema` advertises these under a relationship's `filter.requires`; pass
|
|
32
|
+
both keys together.
|
|
33
|
+
|
|
34
|
+
Resource-specific filters:
|
|
35
|
+
- Some resources accept additional filters advertised in `resource_schema` under
|
|
36
|
+
`resource_filters`. Pass each as a TOP-LEVEL argument (NOT inside `filter`), e.g.
|
|
37
|
+
{ "resource": "...", "<name>": <value> }.
|
|
23
38
|
|
|
24
39
|
Sparse fieldset:
|
|
25
40
|
- fields: names of the attributes and/or relationships to include in each record, as an
|
|
@@ -50,8 +65,8 @@ class McpToolkit::Tools::List < McpToolkit::Tools::Base
|
|
|
50
65
|
},
|
|
51
66
|
filter: {
|
|
52
67
|
type: "object",
|
|
53
|
-
description: "Per-attribute
|
|
54
|
-
"
|
|
68
|
+
description: "Per-attribute filters, e.g. { \"booking_id\": 42 }. See a resource's " \
|
|
69
|
+
"`resource_schema` `filters` for the keys and operators it accepts.",
|
|
55
70
|
additionalProperties: true
|
|
56
71
|
},
|
|
57
72
|
limit: { type: "integer", description: "Page size (default 25, max 100)" },
|
|
@@ -65,7 +80,10 @@ class McpToolkit::Tools::List < McpToolkit::Tools::Base
|
|
|
65
80
|
"resource's `resource_schema` for valid attribute and relationship names."
|
|
66
81
|
}
|
|
67
82
|
},
|
|
68
|
-
required: ["resource"]
|
|
83
|
+
required: ["resource"],
|
|
84
|
+
# Resource-specific filters (resource_schema's `resource_filters`) arrive as
|
|
85
|
+
# top-level arguments, so the schema must not advertise a closed shape.
|
|
86
|
+
additionalProperties: true
|
|
69
87
|
)
|
|
70
88
|
|
|
71
89
|
def self.call(server_context:, resource: nil, account_id: nil, **params)
|
|
@@ -74,7 +92,7 @@ class McpToolkit::Tools::List < McpToolkit::Tools::Base
|
|
|
74
92
|
# the scope check (and so an unknown resource is a clean tool error).
|
|
75
93
|
descriptor = resolve_descriptor(resource, config)
|
|
76
94
|
required_scope = config.registry.required_scope_for(descriptor)
|
|
77
|
-
with_account(server_context, account_id:, required_scope:) do |scope_root|
|
|
95
|
+
with_account(server_context, account_id:, required_scope:, resource: descriptor) do |scope_root|
|
|
78
96
|
McpToolkit::ListExecutor.call(resource: descriptor, scope_root:, params:)
|
|
79
97
|
end
|
|
80
98
|
rescue McpToolkit::Errors::InvalidParams => e
|
|
@@ -7,11 +7,19 @@ class McpToolkit::Tools::ResourceSchema < McpToolkit::Tools::Base
|
|
|
7
7
|
description <<~DESC.strip
|
|
8
8
|
Describe a single read-only resource in detail. Pass the resource name as `resource` (use
|
|
9
9
|
the `resources` tool to discover names). Returns:
|
|
10
|
-
- attributes: every field in the response, each with its `type
|
|
10
|
+
- attributes: every field in the response, each with its `type`, a value `format` hint,
|
|
11
|
+
whether it is `filterable`, and the filter `operators` it accepts
|
|
11
12
|
- relationships: associated resources emitted in the record's `links`; each names the
|
|
12
13
|
`target_resource` it resolves to (callable via `list`/`get`)
|
|
13
14
|
- standard_filters: ids, updated_since, limit, offset (accepted by the `list` tool)
|
|
14
|
-
- filters: the per-attribute equality filter keys the `list` tool accepts
|
|
15
|
+
- filters: the per-attribute equality/operator filter keys the `list` tool accepts in
|
|
16
|
+
its `filter` argument
|
|
17
|
+
- resource_filters: resource-specific filters, if any — each is passed as a TOP-LEVEL
|
|
18
|
+
argument of the `list` tool (NOT inside `filter`), e.g. { "resource": "...",
|
|
19
|
+
"<name>": <value> }
|
|
20
|
+
- filter_examples: ready-to-use `filter` payloads for this resource
|
|
21
|
+
A relationship's `filter` block lists the keys that filter by it; when it names a
|
|
22
|
+
`requires` key (e.g. a polymorphic id needing its type), pass BOTH keys together.
|
|
15
23
|
The `attributes` and `relationships` names are also the valid values for the `fields` sparse
|
|
16
24
|
fieldset argument on `get` / `list`. Call this before `list` to learn a resource's shape.
|
|
17
25
|
DESC
|
|
@@ -31,7 +39,8 @@ class McpToolkit::Tools::ResourceSchema < McpToolkit::Tools::Base
|
|
|
31
39
|
# Resolve the resource FIRST so its effective required scope gates discovery
|
|
32
40
|
# of THIS resource's shape (and an unknown resource is a clean tool error).
|
|
33
41
|
descriptor = resolve_descriptor(resource, config)
|
|
34
|
-
with_authentication(server_context, required_scope: config.registry.required_scope_for(descriptor)
|
|
42
|
+
with_authentication(server_context, required_scope: config.registry.required_scope_for(descriptor),
|
|
43
|
+
resource: descriptor) do
|
|
35
44
|
McpToolkit::ResourceSchema.call(descriptor, registry: config.registry)
|
|
36
45
|
end
|
|
37
46
|
rescue McpToolkit::Errors::InvalidParams => e
|