mcp_toolkit 0.3.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 +432 -2
- data/README.md +315 -34
- data/config/routes.rb +20 -5
- data/lib/mcp_toolkit/auth/authority.rb +2 -2
- data/lib/mcp_toolkit/authority/composite_tool_provider.rb +32 -0
- data/lib/mcp_toolkit/authority/context.rb +45 -0
- data/lib/mcp_toolkit/authority/controller_methods.rb +423 -0
- data/lib/mcp_toolkit/authority/registry_tool_provider.rb +69 -0
- data/lib/mcp_toolkit/authority/single_tool_provider.rb +25 -0
- data/lib/mcp_toolkit/authority/token.rb +124 -0
- data/lib/mcp_toolkit/authority/tools/base.rb +150 -0
- data/lib/mcp_toolkit/authority/tools/get.rb +59 -0
- data/lib/mcp_toolkit/authority/tools/list.rb +132 -0
- data/lib/mcp_toolkit/authority/tools/resource_schema.rb +52 -0
- data/lib/mcp_toolkit/authority/tools/resources.rb +55 -0
- data/lib/mcp_toolkit/authority.rb +24 -0
- data/lib/mcp_toolkit/configuration.rb +362 -5
- data/lib/mcp_toolkit/dispatcher.rb +248 -0
- data/lib/mcp_toolkit/engine.rb +26 -8
- data/lib/mcp_toolkit/engine_controllers.rb +124 -0
- data/lib/mcp_toolkit/filtering.rb +168 -23
- data/lib/mcp_toolkit/gateway/aggregator.rb +142 -0
- data/lib/mcp_toolkit/gateway/client.rb +305 -0
- data/lib/mcp_toolkit/gateway/proxy.rb +70 -0
- data/lib/mcp_toolkit/gateway/unknown_upstream.rb +8 -0
- data/lib/mcp_toolkit/gateway/upstream_call_error.rb +23 -0
- data/lib/mcp_toolkit/gateway/upstream_registry.rb +79 -0
- data/lib/mcp_toolkit/list_executor.rb +65 -4
- data/lib/mcp_toolkit/protocol.rb +106 -0
- data/lib/mcp_toolkit/rate_limiter.rb +73 -0
- data/lib/mcp_toolkit/registry.rb +30 -2
- data/lib/mcp_toolkit/resource.rb +173 -6
- data/lib/mcp_toolkit/resource_schema.rb +135 -13
- data/lib/mcp_toolkit/serializer/association_descriptor.rb +11 -0
- data/lib/mcp_toolkit/serializer/base.rb +2 -2
- data/lib/mcp_toolkit/serializer/target_ref.rb +7 -0
- data/lib/mcp_toolkit/session.rb +18 -10
- data/lib/mcp_toolkit/tool_reference_rewriter.rb +33 -0
- data/lib/mcp_toolkit/tools/authority_base.rb +148 -0
- 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/transport/controller_methods.rb +2 -2
- data/lib/mcp_toolkit/usage_metering/recorder.rb +121 -0
- data/lib/mcp_toolkit/version.rb +1 -1
- data/lib/mcp_toolkit.rb +16 -3
- metadata +42 -2
- data/app/controllers/mcp_toolkit/server_controller.rb +0 -19
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# MCP JSON-RPC protocol constants + helpers for the hand-rolled AUTHORITY
|
|
4
|
+
# dispatcher (McpToolkit::Dispatcher). Based on the Model Context Protocol
|
|
5
|
+
# specification.
|
|
6
|
+
#
|
|
7
|
+
# This is the wire vocabulary of a first-party MCP endpoint that serves its own
|
|
8
|
+
# tools (and, as a gateway, aggregates upstream ones) WITHOUT the official `mcp`
|
|
9
|
+
# SDK in the request path. The SDK-backed satellite path
|
|
10
|
+
# (McpToolkit::Server.build) does not use this module; the two dispatch
|
|
11
|
+
# front-ends coexist by design.
|
|
12
|
+
#
|
|
13
|
+
# The error codes, the success/error response envelopes, and the
|
|
14
|
+
# version-negotiation constants here are the BYTE contract a monetized authority
|
|
15
|
+
# endpoint depends on, so they are kept fixed. `SUPPORTED_VERSIONS` is the module
|
|
16
|
+
# default; a host negotiates against `config.supported_protocol_versions`
|
|
17
|
+
# (defaulting to the same list) so the set is overridable without editing this
|
|
18
|
+
# file.
|
|
19
|
+
module McpToolkit::Protocol
|
|
20
|
+
# Protocol versions the server supports, newest first. The version returned in
|
|
21
|
+
# the `initialize` response is the requested version (if supported) or the
|
|
22
|
+
# latest the server supports, per the MCP spec's version-negotiation rules.
|
|
23
|
+
SUPPORTED_VERSIONS = %w[2025-06-18 2025-03-26 2024-11-05].freeze
|
|
24
|
+
LATEST_VERSION = SUPPORTED_VERSIONS.first
|
|
25
|
+
# Kept for backwards compatibility; prefer LATEST_VERSION going forward.
|
|
26
|
+
VERSION = LATEST_VERSION
|
|
27
|
+
|
|
28
|
+
JSONRPC_VERSION = "2.0"
|
|
29
|
+
|
|
30
|
+
# Error codes per JSON-RPC 2.0 spec.
|
|
31
|
+
module ErrorCodes
|
|
32
|
+
PARSE_ERROR = -32_700
|
|
33
|
+
INVALID_REQUEST = -32_600
|
|
34
|
+
METHOD_NOT_FOUND = -32_601
|
|
35
|
+
INVALID_PARAMS = -32_602
|
|
36
|
+
INTERNAL_ERROR = -32_603
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Base protocol error. `code`/`data` land verbatim in the JSON-RPC `error`
|
|
40
|
+
# object via `#to_h`; the dispatcher turns a raised Error into a top-level
|
|
41
|
+
# JSON-RPC error response (the envelope a client sees for a bad tool arg or an
|
|
42
|
+
# unknown method).
|
|
43
|
+
class Error < StandardError
|
|
44
|
+
attr_reader :code, :data
|
|
45
|
+
|
|
46
|
+
def initialize(message, code:, data: nil)
|
|
47
|
+
super(message)
|
|
48
|
+
@code = code
|
|
49
|
+
@data = data
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def to_h
|
|
53
|
+
error = { code:, message: }
|
|
54
|
+
error[:data] = data if data
|
|
55
|
+
error
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class ParseError < Error
|
|
60
|
+
def initialize(message = "Parse error", data: nil)
|
|
61
|
+
super(message, code: ErrorCodes::PARSE_ERROR, data:)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class InvalidRequest < Error
|
|
66
|
+
def initialize(message = "Invalid request", data: nil)
|
|
67
|
+
super(message, code: ErrorCodes::INVALID_REQUEST, data:)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
class MethodNotFound < Error
|
|
72
|
+
def initialize(method_name, data: nil)
|
|
73
|
+
super("Method not found: #{method_name}", code: ErrorCodes::METHOD_NOT_FOUND, data:)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
class InvalidParams < Error
|
|
78
|
+
def initialize(message = "Invalid params", data: nil)
|
|
79
|
+
super(message, code: ErrorCodes::INVALID_PARAMS, data:)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
class InternalError < Error
|
|
84
|
+
def initialize(message = "Internal error", data: nil)
|
|
85
|
+
super(message, code: ErrorCodes::INTERNAL_ERROR, data:)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
module_function
|
|
90
|
+
|
|
91
|
+
def success_response(id:, result:)
|
|
92
|
+
{
|
|
93
|
+
jsonrpc: JSONRPC_VERSION,
|
|
94
|
+
id:,
|
|
95
|
+
result:
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def error_response(id:, error:)
|
|
100
|
+
{
|
|
101
|
+
jsonrpc: JSONRPC_VERSION,
|
|
102
|
+
id:,
|
|
103
|
+
error: error.is_a?(Error) ? error.to_h : error
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A fixed-window request counter backing the authority transport's built-in rate
|
|
4
|
+
# limiting (McpToolkit::Authority::ControllerMethods#mcp_rate_limit!). It is
|
|
5
|
+
# storage-agnostic: it counts against the injected `cache_store` (any
|
|
6
|
+
# ActiveSupport::Cache::Store — a shared Rails.cache in production, a MemoryStore
|
|
7
|
+
# in a unit test), so a host enables per-principal throttling by setting
|
|
8
|
+
# `config.rate_limit_max_requests` alone, without hand-rolling a limiter.
|
|
9
|
+
#
|
|
10
|
+
# The window is FIXED, not sliding: every request whose time falls in the same
|
|
11
|
+
# `window`-second bucket shares one counter, keyed by that bucket's start
|
|
12
|
+
# (`window_start`); the entry expires after `window` seconds. The counter is
|
|
13
|
+
# incremented once per call, and the request is allowed while the running count is
|
|
14
|
+
# `<= max_requests`, blocked once it exceeds it (so exactly `max_requests`
|
|
15
|
+
# requests pass per window).
|
|
16
|
+
#
|
|
17
|
+
# result = McpToolkit::RateLimiter.new(
|
|
18
|
+
# key: principal.id, max_requests: 1_000, window: 3_600, cache_store: Rails.cache
|
|
19
|
+
# ).call
|
|
20
|
+
# result.allowed? # => false once the count exceeds max_requests
|
|
21
|
+
# result.limit # => 1_000
|
|
22
|
+
# result.remaining # => max_requests - count, floored at 0
|
|
23
|
+
# result.reset_at # => epoch seconds of the next window boundary
|
|
24
|
+
# result.retry_after # => seconds until reset_at (0 when already past)
|
|
25
|
+
#
|
|
26
|
+
# The cache key is namespaced (`mcp_toolkit:rate_limit:<key>:<window_start>`) so a
|
|
27
|
+
# host's own cache entries never collide with the counter.
|
|
28
|
+
class McpToolkit::RateLimiter
|
|
29
|
+
# The outcome of one #call: the throttling decision plus the values the
|
|
30
|
+
# transport renders into the `X-RateLimit-*` / `Retry-After` headers.
|
|
31
|
+
Result = Struct.new(:allowed, :limit, :remaining, :reset_at, :retry_after, keyword_init: true) do
|
|
32
|
+
def allowed?
|
|
33
|
+
allowed
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(key:, max_requests:, cache_store:, window: 3_600, now: Time.now)
|
|
38
|
+
@key = key
|
|
39
|
+
@max_requests = max_requests
|
|
40
|
+
@cache_store = cache_store
|
|
41
|
+
@window = window
|
|
42
|
+
@now = now.to_i
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Increments this window's counter and returns the Result. Called once per
|
|
46
|
+
# request by the transport's rate-limit hook.
|
|
47
|
+
def call
|
|
48
|
+
count = @cache_store.increment(cache_key, 1, expires_in: @window) || 1
|
|
49
|
+
allowed = count <= @max_requests
|
|
50
|
+
|
|
51
|
+
Result.new(
|
|
52
|
+
allowed:,
|
|
53
|
+
limit: @max_requests,
|
|
54
|
+
remaining: allowed ? @max_requests - count : 0,
|
|
55
|
+
reset_at:,
|
|
56
|
+
retry_after: [reset_at - @now, 0].max
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def window_start
|
|
63
|
+
@now - (@now % @window)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def reset_at
|
|
67
|
+
window_start + @window
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def cache_key
|
|
71
|
+
"mcp_toolkit:rate_limit:#{@key}:#{window_start}"
|
|
72
|
+
end
|
|
73
|
+
end
|
data/lib/mcp_toolkit/registry.rb
CHANGED
|
@@ -13,8 +13,33 @@ class McpToolkit::Registry
|
|
|
13
13
|
def initialize
|
|
14
14
|
@resources = {}
|
|
15
15
|
@default_required_permissions_scope = nil
|
|
16
|
+
@resource_extension = nil
|
|
17
|
+
@resource_finalizer = nil
|
|
16
18
|
end
|
|
17
19
|
|
|
20
|
+
# A Module MIXED INTO every Resource before its registration block runs, so a host
|
|
21
|
+
# can add its OWN declaration DSL (its "extras") on top of the gem's built-in
|
|
22
|
+
# `model` / `scope` / `serializer` / `filterable` / `superusers_only!` / `note` /
|
|
23
|
+
# `filter`. The host method typically stores into the generic `Resource#extra`
|
|
24
|
+
# bag; the `resource_finalizer` reads it back. nil (the default) mixes in nothing,
|
|
25
|
+
# so a host with no extras is unaffected. Set ONCE in `configure` (not per reload),
|
|
26
|
+
# since `reset!` preserves it.
|
|
27
|
+
#
|
|
28
|
+
# McpToolkit.registry.resource_extension = MyApp::ResourceExtension # adds `dependencies`
|
|
29
|
+
#
|
|
30
|
+
# @return [Module, nil]
|
|
31
|
+
attr_accessor :resource_extension
|
|
32
|
+
|
|
33
|
+
# A callable run against each Resource AFTER its registration block, so a host can
|
|
34
|
+
# derive gem-native fields from its declared extras — e.g. build a `serializer`
|
|
35
|
+
# from the `model` + declared `dependencies`, or a lazy `filterable`. `->(resource)`.
|
|
36
|
+
# nil (the default) is a no-op. This is the hook that lets a host avoid a parallel
|
|
37
|
+
# registration system: it declares resources DIRECTLY against the gem registry and
|
|
38
|
+
# fills the derived pieces here. Set ONCE in `configure`; preserved across `reset!`.
|
|
39
|
+
#
|
|
40
|
+
# @return [#call, nil]
|
|
41
|
+
attr_accessor :resource_finalizer
|
|
42
|
+
|
|
18
43
|
# Registry-wide DEFAULT required scope, so a satellite declares its scope ONCE
|
|
19
44
|
# for every resource instead of repeating it per resource:
|
|
20
45
|
#
|
|
@@ -32,7 +57,9 @@ class McpToolkit::Registry
|
|
|
32
57
|
|
|
33
58
|
def register(name, &)
|
|
34
59
|
resource = McpToolkit::Resource.new(name)
|
|
60
|
+
resource.extend(@resource_extension) if @resource_extension
|
|
35
61
|
resource.instance_eval(&)
|
|
62
|
+
@resource_finalizer&.call(resource)
|
|
36
63
|
@resources[name.to_s] = resource
|
|
37
64
|
end
|
|
38
65
|
|
|
@@ -59,8 +86,9 @@ class McpToolkit::Registry
|
|
|
59
86
|
end
|
|
60
87
|
|
|
61
88
|
# Clears registered resources for a dev reload (the satellite re-declares them
|
|
62
|
-
# in `to_prepare`). The `default_required_permissions_scope`
|
|
63
|
-
#
|
|
89
|
+
# in `to_prepare`). The `default_required_permissions_scope`, `resource_extension`
|
|
90
|
+
# and `resource_finalizer` are PRESERVED, since they're declared once in
|
|
91
|
+
# `configure` rather than per-reload.
|
|
64
92
|
def reset!
|
|
65
93
|
@resources = {}
|
|
66
94
|
end
|
data/lib/mcp_toolkit/resource.rb
CHANGED
|
@@ -16,6 +16,20 @@
|
|
|
16
16
|
class McpToolkit::Resource
|
|
17
17
|
class NotConfigured < StandardError; end
|
|
18
18
|
|
|
19
|
+
# A resource-specific ("custom") filter: a request-facing key whose value is
|
|
20
|
+
# applied to the relation by an arbitrary host-supplied block, rather than the
|
|
21
|
+
# generic equality/operator allowlist. The block is api-agnostic — it receives
|
|
22
|
+
# the already-scoped relation and the raw request value and returns a narrowed
|
|
23
|
+
# relation — so a host can express a relational or otherwise non-column filter
|
|
24
|
+
# (e.g. "only rows whose associated booking is in this rental") without the gem
|
|
25
|
+
# knowing anything about the query. `type`/`description` are surfaced by
|
|
26
|
+
# resource_schema so a client can discover the filter.
|
|
27
|
+
CustomFilter = Struct.new(:name, :type, :description, :applier, keyword_init: true)
|
|
28
|
+
|
|
29
|
+
# Sentinel distinguishing `extra(:key)` (read) from `extra(:key, nil)` (write nil).
|
|
30
|
+
UNSET = Object.new
|
|
31
|
+
private_constant :UNSET
|
|
32
|
+
|
|
19
33
|
attr_reader :name
|
|
20
34
|
|
|
21
35
|
def initialize(name)
|
|
@@ -24,10 +38,34 @@ class McpToolkit::Resource
|
|
|
24
38
|
@serializer = nil
|
|
25
39
|
@scope_block = nil
|
|
26
40
|
@description = nil
|
|
41
|
+
@note = nil
|
|
42
|
+
@superusers_only = false
|
|
27
43
|
@filterable = {}
|
|
44
|
+
@filterable_source = nil
|
|
45
|
+
@filter_requirements = {}
|
|
46
|
+
@filter_requirements_source = nil
|
|
47
|
+
@custom_filters = {}
|
|
28
48
|
@required_permissions_scope = nil
|
|
49
|
+
@extras = {}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A generic, api-agnostic metadata bag for host-defined "extras" — declarations
|
|
53
|
+
# the gem does not model itself (e.g. an app's ORM dependency list used to build a
|
|
54
|
+
# serializer). It is the storage behind `config.registry.resource_extension` (the
|
|
55
|
+
# host DSL that writes extras inside a registration block) and
|
|
56
|
+
# `config.registry.resource_finalizer` (which reads them back to derive gem-native
|
|
57
|
+
# fields such as `serializer` / `filterable`). Write with `extra(:key, value)`;
|
|
58
|
+
# read with `extra(:key)` (nil when unset). The gem never inspects the values.
|
|
59
|
+
def extra(key, value = UNSET)
|
|
60
|
+
return @extras[key] if value.equal?(UNSET)
|
|
61
|
+
|
|
62
|
+
@extras[key] = value
|
|
29
63
|
end
|
|
30
64
|
|
|
65
|
+
# The full host-extras bag (symbol/whatever key => value). Read-only view for a
|
|
66
|
+
# resource_finalizer that wants to iterate every declared extra.
|
|
67
|
+
attr_reader :extras
|
|
68
|
+
|
|
31
69
|
def model(klass = nil)
|
|
32
70
|
@model = klass if klass
|
|
33
71
|
@model
|
|
@@ -48,6 +86,59 @@ class McpToolkit::Resource
|
|
|
48
86
|
@description
|
|
49
87
|
end
|
|
50
88
|
|
|
89
|
+
# Free-form usage caveat surfaced by the `resources` / `resource_schema` tools,
|
|
90
|
+
# e.g. to flag a resource as internal-debugging-only and not to be interpreted
|
|
91
|
+
# without domain knowledge. Read with no arg. api-agnostic passthrough string.
|
|
92
|
+
def note(text = nil)
|
|
93
|
+
@note = text if text
|
|
94
|
+
@note
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Restricts this resource to superuser (cross-tenant) callers on the AUTHORITY
|
|
98
|
+
# path: an authority tool refuses `get` / `list` / `resource_schema` for a
|
|
99
|
+
# non-superuser and HIDES the resource from `resources` discovery. Declared in a
|
|
100
|
+
# resource's registration block:
|
|
101
|
+
#
|
|
102
|
+
# McpToolkit.registry.register(:audit_events) do
|
|
103
|
+
# superusers_only!
|
|
104
|
+
# ...
|
|
105
|
+
# end
|
|
106
|
+
#
|
|
107
|
+
# Generic and api-agnostic — the gem never names an app concept; the caller's
|
|
108
|
+
# superuser-ness is derived by the Authority::Context off the principal.
|
|
109
|
+
def superusers_only!
|
|
110
|
+
@superusers_only = true
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Whether this resource is restricted to superuser callers (default false).
|
|
114
|
+
def superusers_only?
|
|
115
|
+
@superusers_only
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Declares a resource-specific ("custom") filter: a request-facing `name` whose
|
|
119
|
+
# value is applied to the already-scoped relation by the given block. Unlike the
|
|
120
|
+
# `filterable` allowlist (generic equality/operator filters on a declared
|
|
121
|
+
# column), a custom filter runs ARBITRARY host logic, so a host can express a
|
|
122
|
+
# relational filter the gem could not derive:
|
|
123
|
+
#
|
|
124
|
+
# filter :rental_id, type: :integer, description: "Only rows for this rental" do |relation, value|
|
|
125
|
+
# relation.joins(:booking).where(bookings: { rental_id: value })
|
|
126
|
+
# end
|
|
127
|
+
#
|
|
128
|
+
# The block receives `(relation, value)` and MUST return a relation (narrowing
|
|
129
|
+
# only). `type` / `description` are metadata surfaced by `resource_schema`. The
|
|
130
|
+
# value arrives from a TOP-LEVEL request param keyed by `name` (see ListExecutor),
|
|
131
|
+
# applied BEFORE the allowlist `filterable` filters. api-agnostic: the gem stores
|
|
132
|
+
# and calls the block without inspecting it.
|
|
133
|
+
def filter(name, type:, description:, &applier)
|
|
134
|
+
@custom_filters[name.to_sym] = CustomFilter.new(name: name.to_sym, type:, description:, applier:)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Request-facing custom-filter key (symbol) => CustomFilter. Consumed by the list
|
|
138
|
+
# executor (which applies each block whose key is present in the request params)
|
|
139
|
+
# and by resource_schema (which surfaces each filter's type/description).
|
|
140
|
+
attr_reader :custom_filters
|
|
141
|
+
|
|
51
142
|
# The OAuth-style scope a token MUST carry to reach this resource via the
|
|
52
143
|
# generic tools (e.g. "notifications__read"). Declared explicitly per resource:
|
|
53
144
|
#
|
|
@@ -80,24 +171,64 @@ class McpToolkit::Resource
|
|
|
80
171
|
#
|
|
81
172
|
# Unmapped/unknown keys are rejected by the list executor, never silently
|
|
82
173
|
# dropped, so a typo surfaces as actionable feedback.
|
|
83
|
-
|
|
84
|
-
|
|
174
|
+
# Accepts a Hash (merged now) OR a callable returning a Hash (resolved LAZILY on
|
|
175
|
+
# first read). The lazy form lets a host derive the map from something that must
|
|
176
|
+
# NOT be touched at registration/boot time — e.g. a DB-backed column list
|
|
177
|
+
# (`Model.column_names`): registration typically runs inside an initializer's
|
|
178
|
+
# `to_prepare`, before the database may exist (e.g. CI's `db:create`), so hitting
|
|
179
|
+
# the DB there aborts boot. A callable source is invoked at most once — on the
|
|
180
|
+
# first read (a tool call, when the DB is present) — then memoized.
|
|
181
|
+
def filterable(mapping = nil, &block)
|
|
182
|
+
source = block || mapping
|
|
183
|
+
return filterable_columns if source.nil?
|
|
85
184
|
|
|
86
|
-
|
|
87
|
-
@
|
|
185
|
+
if source.respond_to?(:call)
|
|
186
|
+
@filterable_source = source
|
|
187
|
+
else
|
|
188
|
+
merge_filterable!(source)
|
|
88
189
|
end
|
|
89
|
-
|
|
190
|
+
self
|
|
90
191
|
end
|
|
91
192
|
|
|
92
193
|
# Request-facing filter keys (symbols, sorted) this resource can be filtered
|
|
93
194
|
# by. Surfaced via the `resource_schema` tool.
|
|
94
195
|
def filterable_keys
|
|
95
|
-
|
|
196
|
+
filterable_columns.keys.sort
|
|
197
|
+
end
|
|
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
|
|
96
226
|
end
|
|
97
227
|
|
|
98
228
|
# Request-facing filter key (symbol) => backing column (symbol). Consumed by
|
|
99
229
|
# the list executor to build the WHERE clause.
|
|
100
230
|
def filterable_columns
|
|
231
|
+
resolve_filterable_source!
|
|
101
232
|
@filterable
|
|
102
233
|
end
|
|
103
234
|
|
|
@@ -126,4 +257,40 @@ class McpToolkit::Resource
|
|
|
126
257
|
|
|
127
258
|
serializer.declared_associations
|
|
128
259
|
end
|
|
260
|
+
|
|
261
|
+
private
|
|
262
|
+
|
|
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 `{}`.
|
|
270
|
+
def resolve_filterable_source!
|
|
271
|
+
return unless @filterable_source
|
|
272
|
+
|
|
273
|
+
resolved = @filterable_source.call || {}
|
|
274
|
+
@filterable_source = nil
|
|
275
|
+
merge_filterable!(resolved)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def merge_filterable!(mapping)
|
|
279
|
+
mapping.each { |request_key, column| @filterable[request_key.to_sym] = column.to_sym }
|
|
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
|
|
129
296
|
end
|
|
@@ -38,11 +38,15 @@ class McpToolkit::ResourceSchema
|
|
|
38
38
|
{
|
|
39
39
|
name: resource.name,
|
|
40
40
|
description: resource.description,
|
|
41
|
+
note: resource.note,
|
|
41
42
|
attributes:,
|
|
42
43
|
relationships:,
|
|
44
|
+
resource_filters:,
|
|
43
45
|
standard_filters: STANDARD_FILTERS,
|
|
46
|
+
sparse_fieldsets: true,
|
|
47
|
+
filter_examples:,
|
|
44
48
|
filters:
|
|
45
|
-
}
|
|
49
|
+
}.compact
|
|
46
50
|
end
|
|
47
51
|
|
|
48
52
|
private
|
|
@@ -50,7 +54,7 @@ class McpToolkit::ResourceSchema
|
|
|
50
54
|
attr_reader :resource, :model, :registry
|
|
51
55
|
|
|
52
56
|
def attributes
|
|
53
|
-
resource.attribute_names.map { |name| attribute_schema(name) }
|
|
57
|
+
@attributes ||= resource.attribute_names.map { |name| attribute_schema(name) }
|
|
54
58
|
end
|
|
55
59
|
|
|
56
60
|
def attribute_schema(name)
|
|
@@ -59,8 +63,20 @@ class McpToolkit::ResourceSchema
|
|
|
59
63
|
name:,
|
|
60
64
|
type: type ? type.to_s : COMPUTED_TYPE,
|
|
61
65
|
format: type ? TYPE_FORMATS[type] : nil,
|
|
62
|
-
filterable: filterable_column_for(name).present
|
|
63
|
-
|
|
66
|
+
filterable: filterable_column_for(name).present?,
|
|
67
|
+
operators: operators_for(name)
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The filter operators an attribute accepts, derived from the backing column's
|
|
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.
|
|
75
|
+
def operators_for(attribute_name)
|
|
76
|
+
pair = filterable_column_for(attribute_name)
|
|
77
|
+
return [] unless pair
|
|
78
|
+
|
|
79
|
+
McpToolkit::Filtering.operators_for(column_type(pair.last))
|
|
64
80
|
end
|
|
65
81
|
|
|
66
82
|
# Per-attribute equality filters this resource accepts on the `list` tool's
|
|
@@ -79,6 +95,88 @@ class McpToolkit::ResourceSchema
|
|
|
79
95
|
end
|
|
80
96
|
end
|
|
81
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
|
+
|
|
82
180
|
# Backing column for a serialized attribute that is also a filter key, if any.
|
|
83
181
|
# A filter key may be a public alias (e.g. booking_id -> synced_booking_id) so
|
|
84
182
|
# we match on either the request key or the column.
|
|
@@ -89,22 +187,46 @@ class McpToolkit::ResourceSchema
|
|
|
89
187
|
end
|
|
90
188
|
|
|
91
189
|
def relationships
|
|
92
|
-
resource.association_descriptors.map { |association| relationship_schema(association) }
|
|
190
|
+
@relationships ||= resource.association_descriptors.map { |association| relationship_schema(association) }
|
|
93
191
|
end
|
|
94
192
|
|
|
95
|
-
# One relationship entry
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
# link is discoverably the
|
|
99
|
-
#
|
|
100
|
-
#
|
|
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).
|
|
101
200
|
def relationship_schema(association)
|
|
102
201
|
target = target_resource_for(association)
|
|
103
|
-
{
|
|
202
|
+
schema = {
|
|
104
203
|
name: association.links_key,
|
|
105
204
|
kind: association.type.to_s,
|
|
106
205
|
polymorphic: association.polymorphic || false,
|
|
107
|
-
|
|
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]
|
|
108
230
|
}.compact
|
|
109
231
|
end
|
|
110
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
|
+
)
|
|
@@ -144,8 +144,8 @@ class McpToolkit::Serializer::Base
|
|
|
144
144
|
|
|
145
145
|
# Infer the serialized model from the serializer class name by stripping a
|
|
146
146
|
# trailing "Serializer" and the host namespace, e.g.
|
|
147
|
-
#
|
|
148
|
-
#
|
|
147
|
+
# Api::WidgetSerializer -> Widget
|
|
148
|
+
# Api::ScheduledWidgets::FilterSerializer -> ScheduledWidgets::Filter
|
|
149
149
|
# Subclasses whose name doesn't follow the convention set `model_class`.
|
|
150
150
|
def self.model_class
|
|
151
151
|
@model_class ||= begin
|
|
@@ -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)
|