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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +432 -2
  3. data/README.md +315 -34
  4. data/config/routes.rb +20 -5
  5. data/lib/mcp_toolkit/auth/authority.rb +2 -2
  6. data/lib/mcp_toolkit/authority/composite_tool_provider.rb +32 -0
  7. data/lib/mcp_toolkit/authority/context.rb +45 -0
  8. data/lib/mcp_toolkit/authority/controller_methods.rb +423 -0
  9. data/lib/mcp_toolkit/authority/registry_tool_provider.rb +69 -0
  10. data/lib/mcp_toolkit/authority/single_tool_provider.rb +25 -0
  11. data/lib/mcp_toolkit/authority/token.rb +124 -0
  12. data/lib/mcp_toolkit/authority/tools/base.rb +150 -0
  13. data/lib/mcp_toolkit/authority/tools/get.rb +59 -0
  14. data/lib/mcp_toolkit/authority/tools/list.rb +132 -0
  15. data/lib/mcp_toolkit/authority/tools/resource_schema.rb +52 -0
  16. data/lib/mcp_toolkit/authority/tools/resources.rb +55 -0
  17. data/lib/mcp_toolkit/authority.rb +24 -0
  18. data/lib/mcp_toolkit/configuration.rb +362 -5
  19. data/lib/mcp_toolkit/dispatcher.rb +248 -0
  20. data/lib/mcp_toolkit/engine.rb +26 -8
  21. data/lib/mcp_toolkit/engine_controllers.rb +124 -0
  22. data/lib/mcp_toolkit/filtering.rb +168 -23
  23. data/lib/mcp_toolkit/gateway/aggregator.rb +142 -0
  24. data/lib/mcp_toolkit/gateway/client.rb +305 -0
  25. data/lib/mcp_toolkit/gateway/proxy.rb +70 -0
  26. data/lib/mcp_toolkit/gateway/unknown_upstream.rb +8 -0
  27. data/lib/mcp_toolkit/gateway/upstream_call_error.rb +23 -0
  28. data/lib/mcp_toolkit/gateway/upstream_registry.rb +79 -0
  29. data/lib/mcp_toolkit/list_executor.rb +65 -4
  30. data/lib/mcp_toolkit/protocol.rb +106 -0
  31. data/lib/mcp_toolkit/rate_limiter.rb +73 -0
  32. data/lib/mcp_toolkit/registry.rb +30 -2
  33. data/lib/mcp_toolkit/resource.rb +173 -6
  34. data/lib/mcp_toolkit/resource_schema.rb +135 -13
  35. data/lib/mcp_toolkit/serializer/association_descriptor.rb +11 -0
  36. data/lib/mcp_toolkit/serializer/base.rb +2 -2
  37. data/lib/mcp_toolkit/serializer/target_ref.rb +7 -0
  38. data/lib/mcp_toolkit/session.rb +18 -10
  39. data/lib/mcp_toolkit/tool_reference_rewriter.rb +33 -0
  40. data/lib/mcp_toolkit/tools/authority_base.rb +148 -0
  41. data/lib/mcp_toolkit/tools/base.rb +23 -3
  42. data/lib/mcp_toolkit/tools/get.rb +1 -1
  43. data/lib/mcp_toolkit/tools/list.rb +27 -9
  44. data/lib/mcp_toolkit/tools/resource_schema.rb +12 -3
  45. data/lib/mcp_toolkit/tools/resources.rb +30 -7
  46. data/lib/mcp_toolkit/transport/controller_methods.rb +2 -2
  47. data/lib/mcp_toolkit/usage_metering/recorder.rb +121 -0
  48. data/lib/mcp_toolkit/version.rb +1 -1
  49. data/lib/mcp_toolkit.rb +16 -3
  50. metadata +42 -2
  51. data/app/controllers/mcp_toolkit/server_controller.rb +0 -19
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared base for the four GENERIC, Registry-backed authority tools
4
+ # (McpToolkit::Authority::Tools::{Resources,ResourceSchema,Get,List}) served
5
+ # through McpToolkit::Authority::RegistryToolProvider on the hand-rolled
6
+ # authority dispatch path.
7
+ #
8
+ # Unlike the satellite tools (McpToolkit::Tools::*, which subclass the SDK's
9
+ # MCP::Tool, self-authenticate, and return an MCP::Tool::Response), these are
10
+ # plain objects satisfying the dispatcher's duck-typed tool contract:
11
+ #
12
+ # tool.required_permissions_scope -> nil (see below — no STATIC scope)
13
+ # tool.call(context:, **arguments) -> Hash (the dispatcher wraps it into
14
+ # { content: [{ type: "text", ... }] })
15
+ #
16
+ # The scope a caller needs is DYNAMIC — it depends on which `resource` argument
17
+ # was passed — so these tools declare no static scope and instead enforce the
18
+ # resolved resource's `required_scope_for` INSIDE #call (see #ensure_scope!).
19
+ # The `context` (McpToolkit::Authority::Context) supplies the resolved account,
20
+ # the principal, and the derived superuser flag.
21
+ #
22
+ # The tools reuse the existing executors / schema builder UNCHANGED; this base
23
+ # only holds the resolution + gating every one of them repeats: resolve the
24
+ # resource descriptor, gate a superuser-only resource, gate the per-resource
25
+ # scope, and (for get/list) require a selected account.
26
+ class McpToolkit::Authority::Tools::Base
27
+ class << self
28
+ attr_reader :_description, :_input_schema
29
+
30
+ def tool_name(name = nil)
31
+ @_tool_name = name.to_s if name
32
+ @_tool_name
33
+ end
34
+
35
+ def description(text = nil)
36
+ @_description = text if text
37
+ @_description
38
+ end
39
+
40
+ def input_schema(schema = nil)
41
+ @_input_schema = schema if schema
42
+ @_input_schema || { type: "object", properties: {} }
43
+ end
44
+
45
+ # The static tool definition returned by the provider's `tool_definitions`
46
+ # (part of `tools/list`). Generic and context-independent. `name_prefix` is
47
+ # the host's `config.generic_tool_name_prefix`: it prefixes the advertised
48
+ # name AND is threaded through every sibling-tool reference in the
49
+ # description / input schema (see McpToolkit::ToolReferenceRewriter).
50
+ # `config` lets a tool adapt its prose to host configuration (see
51
+ # .description_text) — served docs must describe the behavior the host
52
+ # actually configured.
53
+ def definition(name_prefix: "", config: nil)
54
+ {
55
+ name: "#{name_prefix}#{tool_name}",
56
+ description: McpToolkit::ToolReferenceRewriter.rewrite(description_text(config), name_prefix),
57
+ inputSchema: McpToolkit::ToolReferenceRewriter.rewrite(input_schema, name_prefix)
58
+ }
59
+ end
60
+
61
+ # Hook for a tool whose description depends on host configuration
62
+ # (Tools::List swaps its bare-value grammar per
63
+ # config.bare_filter_value_semantics). Default: the static description.
64
+ def description_text(_config)
65
+ _description
66
+ end
67
+ end
68
+
69
+ attr_reader :config
70
+
71
+ def initialize(config:)
72
+ @config = config
73
+ end
74
+
75
+ # The dispatcher's central scope gate reads this; nil = no STATIC scope. The
76
+ # real, per-resource scope is enforced dynamically in #ensure_scope! (the scope
77
+ # depends on the `resource` argument, unknown until #call).
78
+ def required_permissions_scope
79
+ nil
80
+ end
81
+
82
+ private
83
+
84
+ def registry
85
+ config.registry
86
+ end
87
+
88
+ # Resolves the `resource` argument to a registered descriptor, raising the
89
+ # protocol InvalidParams (=> JSON-RPC -32602) for a blank or unknown name so the
90
+ # dispatcher renders a clean top-level error.
91
+ def resolve_descriptor(name)
92
+ raise McpToolkit::Protocol::InvalidParams, "resource is required" if name.to_s.strip.empty?
93
+
94
+ registry.fetch(name)
95
+ rescue McpToolkit::Registry::UnknownResource => e
96
+ raise McpToolkit::Protocol::InvalidParams, e.message
97
+ end
98
+
99
+ # Refuses a superuser-only resource for a non-superuser caller (get / list /
100
+ # resource_schema). `resources` HIDES such resources instead — see that tool.
101
+ def ensure_resource_accessible!(descriptor, context)
102
+ return unless descriptor.superusers_only?
103
+ return if context.superuser?
104
+
105
+ raise McpToolkit::Protocol::InvalidRequest,
106
+ "#{descriptor.name} is restricted to superuser (user-scoped) MCP tokens"
107
+ end
108
+
109
+ # Enforces the resource's effective required scope against the principal. Blank
110
+ # scope => no check. Mirrors the dispatcher's central gate error shape
111
+ # (InvalidRequest), keeping scope refusals byte-consistent across host tools.
112
+ def ensure_scope!(descriptor, context)
113
+ required = registry.required_scope_for(descriptor)
114
+ return if required.to_s.empty?
115
+ return if context.principal&.authorized_for_scope?(required)
116
+
117
+ raise McpToolkit::Protocol::InvalidRequest, "This token lacks the #{required.inspect} scope"
118
+ end
119
+
120
+ # get / list read tenant data, so they REQUIRE a resolved account: a superuser
121
+ # token that selected none would otherwise reach `scope.call(nil)` and leak
122
+ # across tenants. resource_schema / resources (shape only) do not call this.
123
+ def ensure_account!(context)
124
+ return if context.account
125
+
126
+ raise McpToolkit::Protocol::InvalidParams,
127
+ "an account must be selected (pass account_id) to read this resource"
128
+ end
129
+
130
+ # Runs an executor, translating a data-layer McpToolkit::Errors::InvalidParams
131
+ # (bad id, unknown filter/field key, ...) into the protocol InvalidParams the
132
+ # dispatcher renders as JSON-RPC -32602 (rather than letting it fall through to
133
+ # the dispatcher's generic -32603 internal-error mapping).
134
+ def run_executor
135
+ yield
136
+ rescue McpToolkit::Errors::InvalidParams => e
137
+ raise McpToolkit::Protocol::InvalidParams, e.message
138
+ end
139
+
140
+ # Rejects tool arguments outside the declared input schema (-32602) instead of
141
+ # silently ignoring them — a typo'd argument name should surface, not no-op.
142
+ # `list` deliberately does NOT use this: its extra top-level arguments are the
143
+ # resource-specific custom filters. (`account_id` is consumed by the
144
+ # transport's account resolution, so every tool tolerates it explicitly.)
145
+ def reject_unknown_arguments!(extra)
146
+ return if extra.empty?
147
+
148
+ raise McpToolkit::Protocol::InvalidParams, "unknown argument(s): #{extra.keys.join(", ")}"
149
+ end
150
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Authority-path tool: fetch a single record by id from a registered resource,
4
+ # scoped to the caller's resolved account. Gates a superuser-only resource, the
5
+ # resource's required scope, and requires a selected account before reading.
6
+ class McpToolkit::Authority::Tools::Get < McpToolkit::Authority::Tools::Base
7
+ tool_name "get"
8
+ description <<~DESC.strip
9
+ Fetch a single record by id from a read-only resource. Pass the resource name as `resource`
10
+ and the record id as `id`. Use the `resources` tool to discover available resources.
11
+
12
+ For tokens that span multiple accounts (superuser), pass `account_id` to pin the active
13
+ account; account-scoped tokens may omit it. The response mirrors the resource's record
14
+ shape (attributes + a `links` block).
15
+
16
+ Pass `fields` to return a sparse fieldset — the attributes and/or relationships you name
17
+ (as an array or comma-separated string), omitting everything else. Include "id" if you need
18
+ it. Valid names come from the resource's `resource_schema`; unknown names are rejected.
19
+ DESC
20
+
21
+ input_schema(
22
+ {
23
+ type: "object",
24
+ properties: {
25
+ resource: {
26
+ type: "string",
27
+ description: "Resource name (use the `resources` tool to discover valid values)"
28
+ },
29
+ # The id type is left open so a string/UUID primary key works as well as an
30
+ # integer one; the record is looked up by the value as given, uncoerced.
31
+ id: { type: %w[string integer], description: "The record ID (integer or string/UUID)" },
32
+ account_id: {
33
+ type: "integer",
34
+ description: "Account to operate on. Required for superuser tokens; ignored otherwise."
35
+ },
36
+ fields: {
37
+ type: %w[array string],
38
+ items: { type: "string" },
39
+ description: "Sparse fieldset — names of attributes and/or relationships to include, as " \
40
+ "an array or a comma-separated string. Omit to return every field. Include " \
41
+ "\"id\" if you need it. Unknown names are rejected."
42
+ }
43
+ },
44
+ required: %w[resource id]
45
+ }
46
+ )
47
+
48
+ def call(context:, resource: nil, id: nil, fields: nil, **extra)
49
+ reject_unknown_arguments!(extra.except(:account_id))
50
+ descriptor = resolve_descriptor(resource)
51
+ ensure_resource_accessible!(descriptor, context)
52
+ ensure_scope!(descriptor, context)
53
+ ensure_account!(context)
54
+
55
+ run_executor do
56
+ McpToolkit::GetExecutor.call(resource: descriptor, scope_root: context.account, id:, fields:)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Authority-path tool: fetch a paginated list of records from a registered
4
+ # resource, scoped to the caller's resolved account. Gates a superuser-only
5
+ # resource, the resource's required scope, and requires a selected account before
6
+ # reading. Standard filters, per-attribute equality/operator filters, resource
7
+ # custom filters, pagination, and sparse fieldsets are all handled by the reused
8
+ # McpToolkit::ListExecutor.
9
+ class McpToolkit::Authority::Tools::List < McpToolkit::Authority::Tools::Base
10
+ # The bare-value grammar bullet, per config.bare_filter_value_semantics: the
11
+ # served description must state the semantics the host ACTUALLY configured —
12
+ # advertising comma/"null" tokenization to clients of a :literal host would
13
+ # send them filters that silently match nothing. The :tokenized text is the
14
+ # exact bullet embedded in the static description below (spec-pinned), so
15
+ # .description_text can swap it by plain substring substitution; both use
16
+ # `<<-` (no dedent) to carry the description's rendered 2-space indentation.
17
+ BARE_VALUE_GRAMMAR = {
18
+ tokenized: <<-TEXT.rstrip,
19
+ - A bare value matches by equality. A comma-separated string or an array of scalars
20
+ matches ANY of the values (IN), e.g. { "status": "booked,canceled" } or
21
+ { "status": ["booked", "canceled"] }. The string "null" (or a JSON null) matches
22
+ records where the value is NULL.
23
+ TEXT
24
+ literal: <<-TEXT.rstrip
25
+ - A bare value matches by equality, LITERALLY: a comma-separated string is a single
26
+ value and the string "null" is the literal string. A JSON null matches records
27
+ where the value is NULL. An array of scalars matches ANY of its values (IN).
28
+ TEXT
29
+ }.freeze
30
+
31
+ # Swaps the bare-value bullet for the host's configured semantics; the rest
32
+ # of the description is mode-independent.
33
+ def self.description_text(config)
34
+ return _description unless config && config.bare_filter_value_semantics == :literal
35
+
36
+ _description.sub(BARE_VALUE_GRAMMAR[:tokenized], BARE_VALUE_GRAMMAR[:literal])
37
+ end
38
+
39
+ tool_name "list"
40
+ description <<~DESC.strip
41
+ Fetch a paginated list of records from a read-only resource. Pass the resource name as
42
+ `resource`. Use the `resources` tool to discover resources and `resource_schema` to learn a
43
+ resource's shape.
44
+
45
+ Standard filters:
46
+ - ids: comma-separated list of IDs to fetch
47
+ - updated_since: ISO 8601 timestamp; only records updated after this time
48
+ - limit: page size (default 25, max 100)
49
+ - offset: pagination offset (default 0)
50
+
51
+ Per-attribute filters:
52
+ - filter: an object of { <key>: <value> } filters, applied ON TOP of the account scope
53
+ (they can only narrow, never widen). Each resource advertises its available filter keys
54
+ and operators via `resource_schema`. Unknown keys are rejected.
55
+ - A bare value matches by equality. A comma-separated string or an array of scalars
56
+ matches ANY of the values (IN), e.g. { "status": "booked,canceled" } or
57
+ { "status": ["booked", "canceled"] }. The string "null" (or a JSON null) matches
58
+ records where the value is NULL.
59
+ - An operator condition is an object { "op": <operator>, "value": <value> }, e.g.
60
+ { "price": { "op": "gteq", "value": 100 } }. An array of conditions ANDs them into a
61
+ range: { "price": [{ "op": "gteq", "value": 100 }, { "op": "lt", "value": 200 }] }.
62
+ Each attribute's supported operators are listed in `resource_schema`.
63
+ - Some filter keys require a companion key (e.g. a polymorphic id and its type) —
64
+ `resource_schema` advertises these under a relationship's `filter.requires`; pass
65
+ both keys together.
66
+
67
+ Resource-specific filters:
68
+ - Some resources accept additional filters advertised in `resource_schema` under
69
+ `resource_filters`. Pass each as a TOP-LEVEL argument (NOT inside `filter`), e.g.
70
+ { "resource": "...", "<name>": <value> }.
71
+
72
+ Sparse fieldset:
73
+ - fields: names of the attributes and/or relationships to include in each record, as an
74
+ array or a comma-separated string. Omit to return every field. Include "id" if you need
75
+ it. Valid names come from a resource's `resource_schema`; unknown names are rejected.
76
+
77
+ For tokens that span multiple accounts (superuser), pass `account_id` to pin the active
78
+ account; account-scoped tokens may omit it. The response shape is
79
+ { "<resource>": [...], "meta": { total_count, limit, offset } }.
80
+ DESC
81
+
82
+ input_schema(
83
+ {
84
+ type: "object",
85
+ properties: {
86
+ resource: {
87
+ type: "string",
88
+ description: "Resource name (use the `resources` tool to discover valid values)"
89
+ },
90
+ account_id: {
91
+ type: "integer",
92
+ description: "Account to operate on. Required for superuser tokens; ignored otherwise."
93
+ },
94
+ ids: { type: "string", description: "Comma-separated list of IDs to fetch" },
95
+ updated_since: {
96
+ type: "string",
97
+ description: "ISO 8601 timestamp; only records updated after this time"
98
+ },
99
+ filter: {
100
+ type: "object",
101
+ description: "Per-attribute filters, e.g. { \"booking_id\": 42 }. See a resource's " \
102
+ "`resource_schema` `filters` for the keys and operators it accepts.",
103
+ additionalProperties: true
104
+ },
105
+ limit: { type: "integer", description: "Page size (default 25, max 100)" },
106
+ offset: { type: "integer", description: "Pagination offset (default 0)" },
107
+ fields: {
108
+ type: %w[array string],
109
+ items: { type: "string" },
110
+ description: "Sparse fieldset — names of attributes and/or relationships to include in " \
111
+ "each record, as an array or a comma-separated string. Omit to return every " \
112
+ "field. Include \"id\" if you need it. Unknown names are rejected."
113
+ }
114
+ },
115
+ required: ["resource"],
116
+ # Resource-specific filters (resource_schema's `resource_filters`) arrive as
117
+ # top-level arguments, so the schema must not advertise a closed shape.
118
+ additionalProperties: true
119
+ }
120
+ )
121
+
122
+ def call(context:, resource: nil, **params)
123
+ descriptor = resolve_descriptor(resource)
124
+ ensure_resource_accessible!(descriptor, context)
125
+ ensure_scope!(descriptor, context)
126
+ ensure_account!(context)
127
+
128
+ run_executor do
129
+ McpToolkit::ListExecutor.call(resource: descriptor, scope_root: context.account, params:)
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Authority-path discovery tool: the detailed schema (attributes with types +
4
+ # filter operators, relationships, filters, note) of one registered resource.
5
+ #
6
+ # Reveals shape, not tenant data, so it does NOT require a selected account — but
7
+ # it still gates a superuser-only resource (refuse) and the resource's required
8
+ # scope, so a caller can't discover the shape of something it can't read.
9
+ class McpToolkit::Authority::Tools::ResourceSchema < McpToolkit::Authority::Tools::Base
10
+ tool_name "resource_schema"
11
+ description <<~DESC.strip
12
+ Describe a single read-only resource in detail. Pass the resource name as `resource` (use
13
+ the `resources` tool to discover names). Returns:
14
+ - attributes: every field in the response, each with its `type`, a value `format` hint,
15
+ whether it is `filterable`, and the filter `operators` it accepts
16
+ - relationships: associated resources emitted in the record's `links`; each names the
17
+ `target_resource` it resolves to (callable via `list`/`get`)
18
+ - standard_filters: ids, updated_since, limit, offset (accepted by the `list` tool)
19
+ - filters: the per-attribute equality/operator filter keys the `list` tool accepts in
20
+ its `filter` argument
21
+ - resource_filters: resource-specific filters, if any — each is passed as a TOP-LEVEL
22
+ argument of the `list` tool (NOT inside `filter`), e.g. { "resource": "...",
23
+ "<name>": <value> }
24
+ - filter_examples: ready-to-use `filter` payloads for this resource
25
+ A relationship's `filter` block lists the keys that filter by it; when it names a
26
+ `requires` key (e.g. a polymorphic id needing its type), pass BOTH keys together.
27
+ The `attributes` and `relationships` names are also the valid values for the `fields` sparse
28
+ fieldset argument on `get` / `list`. Call this before `list` to learn a resource's shape.
29
+ DESC
30
+
31
+ input_schema(
32
+ {
33
+ type: "object",
34
+ properties: {
35
+ resource: {
36
+ type: "string",
37
+ description: "Resource name (use the `resources` tool to discover valid values)"
38
+ }
39
+ },
40
+ required: ["resource"]
41
+ }
42
+ )
43
+
44
+ def call(context:, resource: nil, **extra)
45
+ reject_unknown_arguments!(extra.except(:account_id))
46
+ descriptor = resolve_descriptor(resource)
47
+ ensure_resource_accessible!(descriptor, context)
48
+ ensure_scope!(descriptor, context)
49
+
50
+ McpToolkit::ResourceSchema.call(descriptor, registry:)
51
+ end
52
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Authority-path discovery tool: lists every read-only resource registered in the
4
+ # config's Registry, HIDING superuser-only resources from a non-superuser caller
5
+ # (so they are neither advertised nor discoverable without a superuser token).
6
+ #
7
+ # The top-level list is context-independent except for that visibility filter, so
8
+ # the tool takes no arguments and does no per-resource scope check (per-resource
9
+ # scopes are enforced by `get` / `list` / `resource_schema`).
10
+ class McpToolkit::Authority::Tools::Resources < McpToolkit::Authority::Tools::Base
11
+ tool_name "resources"
12
+ description <<~DESC.strip
13
+ List all read-only resources available via the `list` and `get` tools. Returns each
14
+ resource's name, a short description, whether it accepts filters (`filterable`) and — when
15
+ present — a usage `note` (read it before interpreting the resource's data). Call this once
16
+ at the start of a session to learn what exists, then use `resource_schema` for a specific
17
+ resource's attributes, relationships and filters.
18
+ DESC
19
+
20
+ # Unlike get / resource_schema (strict kwargs pre-gem), the pre-gem resources
21
+ # tool tolerated ANY extra argument — so this one stays tolerant.
22
+ def call(context:, **_args)
23
+ {
24
+ resources: visible_resources(context).map do |resource|
25
+ {
26
+ name: resource.name,
27
+ description: resource.description,
28
+ filterable: filterable?(resource),
29
+ note: resource.note
30
+ }.compact
31
+ end
32
+ }
33
+ end
34
+
35
+ private
36
+
37
+ # Superuser-only resources are hidden from a non-superuser caller.
38
+ def visible_resources(context)
39
+ registry.resources.reject { |resource| resource.superusers_only? && !context.superuser? }
40
+ end
41
+
42
+ # Whether the resource can be filtered at all — via the generic allowlist OR a
43
+ # resource-specific custom filter. Reading `filterable_columns` resolves a
44
+ # lazily-declared allowlist, which may run host code (e.g. a DB-backed column
45
+ # list). One resource's failing resolution must not take down the whole
46
+ # discovery index — this is the tool every session calls first — so a raise
47
+ # degrades to nil (the `filterable` key is omitted for that resource) and the
48
+ # unresolved source is retried on the next read (see Resource#filterable).
49
+ def filterable?(resource)
50
+ resource.filterable_columns.any? || resource.custom_filters.any?
51
+ rescue StandardError => e
52
+ config.logger&.warn("mcp_toolkit: filterable resolution failed for #{resource.name}: #{e.message}")
53
+ nil
54
+ end
55
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Namespace for the AUTHORITY-side building blocks: the per-request Context, the
4
+ # transport concern (Authority::ControllerMethods), and the lazily-parented base
5
+ # controller (Authority::ServerController).
6
+ #
7
+ # `ServerController` is NOT a file under this directory: like the engine's
8
+ # controllers, it subclasses a host controller (`config.parent_controller`) that
9
+ # is absent from the gem's own unit suite, so it cannot be a Zeitwerk-managed
10
+ # file (eager-loading it would raise). It is built on demand by
11
+ # `McpToolkit.build_engine_controllers!`, so this `const_missing` triggers that
12
+ # build the first time `McpToolkit::Authority::ServerController` is referenced
13
+ # (which, in a host, is after the app's initializers/to_prepare have run — so the
14
+ # configured parent is read at build time, not at autoload time).
15
+ module McpToolkit::Authority
16
+ def self.const_missing(name)
17
+ if name == :ServerController
18
+ McpToolkit.build_engine_controllers!
19
+ return const_get(name) if const_defined?(name, false)
20
+ end
21
+
22
+ super
23
+ end
24
+ end