mcp_toolkit 0.4.0 → 0.6.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 +418 -0
- data/README.md +174 -0
- data/app/views/mcp_toolkit/oauth/authorize.html.erb +83 -0
- data/config/routes.rb +22 -1
- data/lib/mcp_toolkit/authority/controller_methods.rb +39 -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 +526 -7
- data/lib/mcp_toolkit/dispatcher.rb +17 -3
- data/lib/mcp_toolkit/engine.rb +20 -2
- data/lib/mcp_toolkit/engine_controllers.rb +80 -5
- 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/oauth/controller_methods.rb +426 -0
- data/lib/mcp_toolkit/oauth.rb +23 -0
- 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
- data/lib/mcp_toolkit.rb +8 -2
- metadata +8 -1
|
@@ -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
|
|
@@ -5,9 +5,10 @@ class McpToolkit::Tools::Resources < McpToolkit::Tools::Base
|
|
|
5
5
|
tool_name "resources"
|
|
6
6
|
description <<~DESC.strip
|
|
7
7
|
List all read-only resources available via the `list` and `get` tools. Returns each
|
|
8
|
-
resource's name
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
resource's name, a short description, whether it accepts filters (`filterable`) and — when
|
|
9
|
+
present — a usage `note` (read it before interpreting the resource's data). Call this once
|
|
10
|
+
at the start of a session to learn what exists, then use `resource_schema` for a specific
|
|
11
|
+
resource's attributes, relationships and filters.
|
|
11
12
|
DESC
|
|
12
13
|
|
|
13
14
|
input_schema(properties: {})
|
|
@@ -16,12 +17,34 @@ class McpToolkit::Tools::Resources < McpToolkit::Tools::Base
|
|
|
16
17
|
config = config_from(server_context)
|
|
17
18
|
# Discovery requires the registry-level default scope (the satellite's
|
|
18
19
|
# app-wide scope); per-resource scopes are enforced by `get` / `list`.
|
|
19
|
-
with_authentication(server_context,
|
|
20
|
+
with_authentication(server_context,
|
|
21
|
+
required_scope: config.registry.default_required_permissions_scope) do |introspection|
|
|
20
22
|
{
|
|
21
|
-
resources: config.registry.resources
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
resources: config.registry.resources
|
|
24
|
+
.reject { |resource| resource.superusers_only? && !introspection.superuser? }
|
|
25
|
+
.map do |resource|
|
|
26
|
+
{
|
|
27
|
+
name: resource.name,
|
|
28
|
+
description: resource.description,
|
|
29
|
+
filterable: filterable?(resource, config),
|
|
30
|
+
note: resource.note
|
|
31
|
+
}.compact
|
|
32
|
+
end
|
|
24
33
|
}
|
|
25
34
|
end
|
|
26
35
|
end
|
|
36
|
+
|
|
37
|
+
# Whether the resource can be filtered at all — via the generic allowlist OR a
|
|
38
|
+
# resource-specific custom filter. Mirrors the authority-path resources tool:
|
|
39
|
+
# resolving a lazily-declared allowlist may run host code, and one resource's
|
|
40
|
+
# failing resolution must not take down the whole discovery index, so a raise
|
|
41
|
+
# degrades to nil (the key is omitted) and the source is retried on the next
|
|
42
|
+
# read (see Resource#filterable).
|
|
43
|
+
def self.filterable?(resource, config)
|
|
44
|
+
resource.filterable_columns.any? || resource.custom_filters.any?
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
config.logger&.warn("mcp_toolkit: filterable resolution failed for #{resource.name}: #{e.message}")
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
private_class_method :filterable?
|
|
27
50
|
end
|
|
@@ -65,18 +65,44 @@ class McpToolkit::UsageMetering::Recorder
|
|
|
65
65
|
end
|
|
66
66
|
|
|
67
67
|
# `config.usage_flusher` target. Persists the request's accumulated events via the
|
|
68
|
-
# sink in one shot. No-op when nothing was accumulated.
|
|
68
|
+
# sink in one shot. No-op when nothing was accumulated. If the batch write fails,
|
|
69
|
+
# falls back to per-event writes so one un-persistable ("poison") event can't drop
|
|
70
|
+
# metering for the whole request.
|
|
69
71
|
def flush(controller:)
|
|
70
72
|
events = buffer_for(controller)
|
|
71
73
|
return if events.empty?
|
|
72
74
|
|
|
73
75
|
@sink.call(events)
|
|
74
76
|
rescue StandardError => e
|
|
75
|
-
|
|
77
|
+
flush_individually(events, e)
|
|
76
78
|
end
|
|
77
79
|
|
|
78
80
|
private
|
|
79
81
|
|
|
82
|
+
# The batch write failed. Retry each event on its own so a single un-persistable
|
|
83
|
+
# event (a constraint violation, bad encoding) loses ONLY itself instead of
|
|
84
|
+
# dropping every sibling call's metering — which a caller could otherwise
|
|
85
|
+
# exploit to evade billing for a whole batch by appending one poison call.
|
|
86
|
+
# Assumes the batch sink is atomic (nothing persisted on failure — true for
|
|
87
|
+
# `insert_all`); a non-atomic sink could double-write the events that did land,
|
|
88
|
+
# so this stays a fallback, not the default path.
|
|
89
|
+
def flush_individually(events, batch_error)
|
|
90
|
+
@logger&.warn(
|
|
91
|
+
"MCP usage tracking: batch flush of #{events.size} event(s) failed " \
|
|
92
|
+
"(#{batch_error.message}), retrying individually"
|
|
93
|
+
)
|
|
94
|
+
events.each do |event|
|
|
95
|
+
@sink.call([event])
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
report("dropped 1 unpersistable usage event", e)
|
|
98
|
+
end
|
|
99
|
+
rescue StandardError
|
|
100
|
+
# Metering MUST NEVER affect the MCP response. `flush` already runs this from
|
|
101
|
+
# its rescue, so a raise here (e.g. a misbehaving logger or error_reporter)
|
|
102
|
+
# would escape into the after_action. Swallow it as the last resort.
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
80
106
|
def scrub(arguments)
|
|
81
107
|
hash = arguments.to_h
|
|
82
108
|
@parameter_filter ? @parameter_filter.filter(hash) : hash
|
data/lib/mcp_toolkit/version.rb
CHANGED
data/lib/mcp_toolkit.rb
CHANGED
|
@@ -8,12 +8,15 @@ require "zeitwerk"
|
|
|
8
8
|
# subfiles that happen to touch them:
|
|
9
9
|
#
|
|
10
10
|
# json - JSON.parse / JSON.generate (introspection parse, tools, transport)
|
|
11
|
-
# digest - Digest::SHA256 (introspection cache key)
|
|
11
|
+
# digest - Digest::SHA256 (introspection cache key; OAuth bridge PKCE digest)
|
|
12
12
|
# time - Time.iso8601 / Time.parse (introspection expiry parsing)
|
|
13
|
-
# securerandom - SecureRandom.uuid (Session ids)
|
|
13
|
+
# securerandom - SecureRandom.uuid (Session ids; OAuth bridge codes/client ids)
|
|
14
|
+
# uri - URI.parse / encode_www_form (OAuth bridge redirect construction)
|
|
14
15
|
# mcp - the official MCP SDK (Server wraps it; Tools::Base subclasses MCP::Tool)
|
|
15
16
|
# active_support/concern - Transport::ControllerMethods is an includable concern
|
|
16
17
|
# active_support/cache - the default MemoryStore cache_store
|
|
18
|
+
# active_support/security_utils - constant-time compare (OAuth bridge PKCE)
|
|
19
|
+
# active_support/message_encryptor - encrypts the OAuth bridge's cached code payload
|
|
17
20
|
#
|
|
18
21
|
# Two third-party libs are the exception to the centralize-here rule: each is
|
|
19
22
|
# required alongside its owner file rather than up front.
|
|
@@ -25,9 +28,12 @@ require "json"
|
|
|
25
28
|
require "digest"
|
|
26
29
|
require "time"
|
|
27
30
|
require "securerandom"
|
|
31
|
+
require "uri"
|
|
28
32
|
require "mcp"
|
|
29
33
|
require "active_support/concern"
|
|
30
34
|
require "active_support/cache"
|
|
35
|
+
require "active_support/security_utils"
|
|
36
|
+
require "active_support/message_encryptor"
|
|
31
37
|
|
|
32
38
|
# External dependencies (NOT autoloaded by Zeitwerk — only the gem's own tree is).
|
|
33
39
|
# ActiveSupport's specific core extensions are required up front (rather than full
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mcp_toolkit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Karol Galanciak
|
|
@@ -98,6 +98,7 @@ files:
|
|
|
98
98
|
- LICENSE.txt
|
|
99
99
|
- README.md
|
|
100
100
|
- Rakefile
|
|
101
|
+
- app/views/mcp_toolkit/oauth/authorize.html.erb
|
|
101
102
|
- config/routes.rb
|
|
102
103
|
- lib/mcp_toolkit.rb
|
|
103
104
|
- lib/mcp_toolkit/auth/authenticator.rb
|
|
@@ -109,6 +110,7 @@ files:
|
|
|
109
110
|
- lib/mcp_toolkit/authority/context.rb
|
|
110
111
|
- lib/mcp_toolkit/authority/controller_methods.rb
|
|
111
112
|
- lib/mcp_toolkit/authority/registry_tool_provider.rb
|
|
113
|
+
- lib/mcp_toolkit/authority/single_tool_provider.rb
|
|
112
114
|
- lib/mcp_toolkit/authority/token.rb
|
|
113
115
|
- lib/mcp_toolkit/authority/tools/base.rb
|
|
114
116
|
- lib/mcp_toolkit/authority/tools/get.rb
|
|
@@ -133,17 +135,22 @@ files:
|
|
|
133
135
|
- lib/mcp_toolkit/gateway/upstream_registry.rb
|
|
134
136
|
- lib/mcp_toolkit/get_executor.rb
|
|
135
137
|
- lib/mcp_toolkit/list_executor.rb
|
|
138
|
+
- lib/mcp_toolkit/oauth.rb
|
|
139
|
+
- lib/mcp_toolkit/oauth/controller_methods.rb
|
|
136
140
|
- lib/mcp_toolkit/protocol.rb
|
|
137
141
|
- lib/mcp_toolkit/rate_limiter.rb
|
|
138
142
|
- lib/mcp_toolkit/registry.rb
|
|
139
143
|
- lib/mcp_toolkit/resource.rb
|
|
140
144
|
- lib/mcp_toolkit/resource_schema.rb
|
|
141
145
|
- lib/mcp_toolkit/serialization.rb
|
|
146
|
+
- lib/mcp_toolkit/serializer/association_descriptor.rb
|
|
142
147
|
- lib/mcp_toolkit/serializer/base.rb
|
|
148
|
+
- lib/mcp_toolkit/serializer/target_ref.rb
|
|
143
149
|
- lib/mcp_toolkit/server.rb
|
|
144
150
|
- lib/mcp_toolkit/session.rb
|
|
145
151
|
- lib/mcp_toolkit/sql_sanitizer.rb
|
|
146
152
|
- lib/mcp_toolkit/token_kinds.rb
|
|
153
|
+
- lib/mcp_toolkit/tool_reference_rewriter.rb
|
|
147
154
|
- lib/mcp_toolkit/tools/authority_base.rb
|
|
148
155
|
- lib/mcp_toolkit/tools/base.rb
|
|
149
156
|
- lib/mcp_toolkit/tools/get.rb
|