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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +418 -0
  3. data/README.md +174 -0
  4. data/app/views/mcp_toolkit/oauth/authorize.html.erb +83 -0
  5. data/config/routes.rb +22 -1
  6. data/lib/mcp_toolkit/authority/controller_methods.rb +39 -0
  7. data/lib/mcp_toolkit/authority/registry_tool_provider.rb +9 -10
  8. data/lib/mcp_toolkit/authority/single_tool_provider.rb +25 -0
  9. data/lib/mcp_toolkit/authority/tools/base.rb +31 -3
  10. data/lib/mcp_toolkit/authority/tools/get.rb +2 -1
  11. data/lib/mcp_toolkit/authority/tools/list.rb +49 -1
  12. data/lib/mcp_toolkit/authority/tools/resource_schema.rb +10 -2
  13. data/lib/mcp_toolkit/authority/tools/resources.rb +26 -4
  14. data/lib/mcp_toolkit/configuration.rb +526 -7
  15. data/lib/mcp_toolkit/dispatcher.rb +17 -3
  16. data/lib/mcp_toolkit/engine.rb +20 -2
  17. data/lib/mcp_toolkit/engine_controllers.rb +80 -5
  18. data/lib/mcp_toolkit/filtering.rb +168 -23
  19. data/lib/mcp_toolkit/gateway/aggregator.rb +25 -5
  20. data/lib/mcp_toolkit/list_executor.rb +48 -4
  21. data/lib/mcp_toolkit/oauth/controller_methods.rb +426 -0
  22. data/lib/mcp_toolkit/oauth.rb +23 -0
  23. data/lib/mcp_toolkit/resource.rb +55 -6
  24. data/lib/mcp_toolkit/resource_schema.rb +125 -16
  25. data/lib/mcp_toolkit/serializer/association_descriptor.rb +11 -0
  26. data/lib/mcp_toolkit/serializer/target_ref.rb +7 -0
  27. data/lib/mcp_toolkit/session.rb +2 -2
  28. data/lib/mcp_toolkit/tool_reference_rewriter.rb +33 -0
  29. data/lib/mcp_toolkit/tools/authority_base.rb +23 -2
  30. data/lib/mcp_toolkit/tools/base.rb +23 -3
  31. data/lib/mcp_toolkit/tools/get.rb +1 -1
  32. data/lib/mcp_toolkit/tools/list.rb +27 -9
  33. data/lib/mcp_toolkit/tools/resource_schema.rb +12 -3
  34. data/lib/mcp_toolkit/tools/resources.rb +30 -7
  35. data/lib/mcp_toolkit/usage_metering/recorder.rb +28 -2
  36. data/lib/mcp_toolkit/version.rb +1 -1
  37. data/lib/mcp_toolkit.rb +8 -2
  38. metadata +8 -1
@@ -0,0 +1,83 @@
1
+ <%#
2
+ The bridge's only human-facing surface: paste an access token you already hold.
3
+
4
+ Rendered with `layout: false`, so this is a whole document and carries its own
5
+ styles inline — it must not depend on the host's asset pipeline. A host that
6
+ wants its own branding defines app/views/mcp_toolkit/oauth/authorize.html.erb
7
+ in its own tree, which takes precedence over this one.
8
+
9
+ The authorization parameters ride through as hidden fields because the bridge
10
+ keeps no state between the two legs. They are re-validated on POST — the
11
+ redirect_uri against the allowlist — so a tampered field cannot widen anything.
12
+ %>
13
+ <!DOCTYPE html>
14
+ <html lang="en">
15
+ <head>
16
+ <meta charset="utf-8">
17
+ <meta name="viewport" content="width=device-width, initial-scale=1">
18
+ <meta name="robots" content="noindex, nofollow">
19
+ <title>Connect to <%= McpToolkit.config.server_name %></title>
20
+ <style>
21
+ :root { color-scheme: light dark; }
22
+ body {
23
+ margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
24
+ padding: 1.5rem; box-sizing: border-box;
25
+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif; line-height: 1.5;
26
+ background: #f4f4f5; color: #18181b;
27
+ }
28
+ main { width: 100%; max-width: 26rem; background: #fff; border-radius: 0.75rem; padding: 2rem;
29
+ box-shadow: 0 1px 3px rgb(0 0 0 / 0.1), 0 8px 24px rgb(0 0 0 / 0.06); }
30
+ h1 { margin: 0 0 0.5rem; font-size: 1.25rem; }
31
+ p { margin: 0 0 1.5rem; color: #52525b; font-size: 0.9375rem; }
32
+ label { display: block; margin-bottom: 0.375rem; font-size: 0.875rem; font-weight: 600; }
33
+ input[type="password"] {
34
+ width: 100%; box-sizing: border-box; padding: 0.625rem 0.75rem; font-size: 1rem;
35
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
36
+ border: 1px solid #d4d4d8; border-radius: 0.375rem; background: #fff; color: inherit;
37
+ }
38
+ input[type="password"]:focus { outline: 2px solid #2563eb; outline-offset: 1px; border-color: #2563eb; }
39
+ button {
40
+ width: 100%; margin-top: 1.25rem; padding: 0.625rem 1rem; font-size: 0.9375rem; font-weight: 600;
41
+ color: #fff; background: #18181b; border: 0; border-radius: 0.375rem; cursor: pointer;
42
+ }
43
+ button:hover { background: #3f3f46; }
44
+ .error {
45
+ margin: 0 0 1.25rem; padding: 0.75rem; border-radius: 0.375rem; font-size: 0.875rem;
46
+ background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;
47
+ }
48
+ @media (prefers-color-scheme: dark) {
49
+ body { background: #18181b; color: #fafafa; }
50
+ main { background: #27272a; box-shadow: none; border: 1px solid #3f3f46; }
51
+ p { color: #a1a1aa; }
52
+ input[type="password"] { background: #18181b; border-color: #52525b; }
53
+ button { background: #fafafa; color: #18181b; }
54
+ button:hover { background: #d4d4d8; }
55
+ .error { background: #450a0a; border-color: #7f1d1d; color: #fecaca; }
56
+ }
57
+ </style>
58
+ </head>
59
+ <body>
60
+ <main>
61
+ <h1>Connect to <%= McpToolkit.config.server_name %></h1>
62
+ <p>Paste your access token to finish connecting. It is not shown again after this step.</p>
63
+
64
+ <% if @mcp_oauth_error.present? %>
65
+ <div class="error" role="alert"><%= @mcp_oauth_error %></div>
66
+ <% end %>
67
+
68
+ <%= form_tag request.path, method: :post do %>
69
+ <%= hidden_field_tag :redirect_uri, params[:redirect_uri] %>
70
+ <%= hidden_field_tag :state, params[:state] %>
71
+ <%= hidden_field_tag :code_challenge, params[:code_challenge] %>
72
+ <%= hidden_field_tag :code_challenge_method, params[:code_challenge_method] %>
73
+
74
+ <label for="access_token">Access token</label>
75
+ <%= password_field_tag :access_token, nil, id: "access_token", autocomplete: "off",
76
+ autocapitalize: "off", autocorrect: "off", spellcheck: false,
77
+ autofocus: true, required: true %>
78
+
79
+ <button type="submit">Connect</button>
80
+ <% end %>
81
+ </main>
82
+ </body>
83
+ </html>
data/config/routes.rb CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Engine routes, drawn through Rails' routes_reloader so they survive route
4
4
  # reloads (a class-body `routes.draw` is wiped when the app re-draws the engine's
5
- # route set on boot/reload). Mounted by a satellite via:
5
+ # route set on boot/reload). Mounted by a satellite or an authority via:
6
6
  #
7
7
  # mount McpToolkit::Engine => "/mcp"
8
8
  #
@@ -30,4 +30,25 @@ McpToolkit::Engine.routes.draw do
30
30
  # is true whenever `auth_role == :authority`). The controller also fails safe
31
31
  # (no `token_authenticator` => `{ valid: false }`), so this is defence in depth.
32
32
  post "tokens/introspect", to: "tokens#introspect" if McpToolkit.config.authority?
33
+
34
+ # The OAuth authorization bridge (McpToolkit::Oauth::ControllerMethods). Drawn
35
+ # only when the bridge is configured — `oauth_bridge?` is authority-only AND
36
+ # requires a redirect-uri allowlist — so a satellite, or any host that has not
37
+ # opted in, gets no such routes at all. Same reasoning as the introspection
38
+ # route above: the routes file is evaluated through the routes_reloader, after
39
+ # the host's initializers/to_prepare, so the config is already set.
40
+ #
41
+ # The two metadata documents are NOT here: a client looks for them at the origin
42
+ # root, which an engine mounted under a path cannot draw. The host draws them
43
+ # with `McpToolkit.draw_oauth_metadata_routes(self)`.
44
+ # `format: false` on each, as on the metadata routes the host draws: without it
45
+ # Rails' optional `(.:format)` segment matches, so `/mcp/oauth/authorize.json`
46
+ # reaches the action, finds no JSON template, and 500s — an unauthenticated
47
+ # error on a public endpoint, for a format the bridge never speaks.
48
+ if McpToolkit.config.oauth_bridge?
49
+ get "oauth/authorize", to: "oauth#authorize", format: false
50
+ post "oauth/authorize", to: "oauth#approve", format: false
51
+ post "oauth/token", to: "oauth#token", format: false
52
+ post "oauth/register", to: "oauth#register", format: false
53
+ end
33
54
  end
@@ -247,12 +247,27 @@ module McpToolkit::Authority::ControllerMethods
247
247
  # ---- request handling -----------------------------------------------
248
248
 
249
249
  def mcp_handle_batch(requests)
250
+ mcp_enforce_batch_limit!(requests)
250
251
  responses = requests.filter_map { |req| mcp_process_single_request(req) }
251
252
  return head :accepted if responses.empty?
252
253
 
253
254
  mcp_render_response(responses)
254
255
  end
255
256
 
257
+ # Rejects an oversized JSON-RPC batch BEFORE any element runs. Rate limiting is
258
+ # a per-HTTP-request before_action, so without this an authenticated caller
259
+ # could fan out unbounded work (N tool executions / N blocking upstream calls)
260
+ # under a single rate-limit tick. Raised as a boundary Protocol::Error →
261
+ # rendered as a JSON-RPC error envelope (null id) by the rescue_from hook.
262
+ # `config.max_batch_size = nil` disables the cap.
263
+ def mcp_enforce_batch_limit!(requests)
264
+ max = mcp_config.max_batch_size
265
+ return if max.nil? || requests.size <= max
266
+
267
+ raise McpToolkit::Protocol::InvalidRequest,
268
+ "JSON-RPC batch too large: #{requests.size} requests exceeds the maximum of #{max}"
269
+ end
270
+
256
271
  def mcp_handle_single(request_data)
257
272
  response_data = mcp_process_single_request(request_data)
258
273
  return head :accepted if response_data.nil?
@@ -366,6 +381,7 @@ module McpToolkit::Authority::ControllerMethods
366
381
  # ---- error renders --------------------------------------------------
367
382
 
368
383
  def mcp_render_unauthorized(message)
384
+ mcp_set_authenticate_challenge
369
385
  render json: {
370
386
  jsonrpc: McpToolkit::Protocol::JSONRPC_VERSION,
371
387
  id: nil,
@@ -376,6 +392,29 @@ module McpToolkit::Authority::ControllerMethods
376
392
  }, status: :unauthorized
377
393
  end
378
394
 
395
+ # Points an unauthenticated caller at the OAuth bridge's protected-resource
396
+ # metadata (RFC 9728). This header is what a hosted MCP client waits for before
397
+ # it will start an authorization flow at all — without it, a 401 is just a
398
+ # failure. It also makes the metadata's location OURS to state rather than the
399
+ # client's to guess: RFC 9728 has the client fetch this URL directly, so the
400
+ # path-scoped location is found without probing the origin's bare well-known
401
+ # path. Emitted only when the bridge is configured, so a host that has not opted
402
+ # in keeps its 401 byte-identical.
403
+ #
404
+ # `request.base_url` honours `X-Forwarded-Host`, so it is caller-influenced and
405
+ # cannot be interpolated into a quoted-string parameter unescaped: a host
406
+ # carrying a `"` would close the quotes and let a caller append auth-params of
407
+ # their own. A URL has no business containing one, so refuse rather than escape
408
+ # — an origin that odd is a misconfiguration to notice, not to render.
409
+ def mcp_set_authenticate_challenge
410
+ return unless mcp_config.oauth_bridge?
411
+
412
+ metadata_url = "#{request.base_url}#{mcp_config.oauth_protected_resource_path}"
413
+ return if metadata_url.include?('"')
414
+
415
+ response.headers["WWW-Authenticate"] = %(Bearer resource_metadata="#{metadata_url}")
416
+ end
417
+
379
418
  def mcp_render_session_not_found
380
419
  render json: {
381
420
  jsonrpc: McpToolkit::Protocol::JSONRPC_VERSION,
@@ -20,11 +20,13 @@ class McpToolkit::Authority::RegistryToolProvider
20
20
  # actually advertised in `tools/list` and matched in `tools/call` is this base
21
21
  # name PREFIXED with `config.generic_tool_name_prefix` (empty by default, so the
22
22
  # bare base name), letting a host namespace its generic tools.
23
+ # Alphabetical by base name — the order tools/list advertises them in
24
+ # (matches the pre-gem contract adopting hosts' clients observed).
23
25
  TOOLS = {
24
- "resources" => McpToolkit::Authority::Tools::Resources,
25
- "resource_schema" => McpToolkit::Authority::Tools::ResourceSchema,
26
26
  "get" => McpToolkit::Authority::Tools::Get,
27
- "list" => McpToolkit::Authority::Tools::List
27
+ "list" => McpToolkit::Authority::Tools::List,
28
+ "resource_schema" => McpToolkit::Authority::Tools::ResourceSchema,
29
+ "resources" => McpToolkit::Authority::Tools::Resources
28
30
  }.freeze
29
31
 
30
32
  def initialize(config:)
@@ -32,9 +34,11 @@ class McpToolkit::Authority::RegistryToolProvider
32
34
  end
33
35
 
34
36
  # The four static generic tool definitions (context-independent), each advertised
35
- # under its PREFIXED name so `tools/list` shows the host's namespaced names.
37
+ # under its PREFIXED name so `tools/list` shows the host's namespaced names. The
38
+ # prefix is threaded into each definition so sibling-tool references in the
39
+ # description / input schema name the prefixed tools too (see Tools::Base.definition).
36
40
  def tool_definitions(_context)
37
- TOOLS.map { |base_name, klass| klass.definition.merge(name: prefixed(base_name)) }
41
+ TOOLS.map { |_base_name, klass| klass.definition(name_prefix: prefix, config: @config) }
38
42
  end
39
43
 
40
44
  # A tool instance bound to this provider's config, or nil for an unknown name.
@@ -54,11 +58,6 @@ class McpToolkit::Authority::RegistryToolProvider
54
58
  @config.generic_tool_name_prefix.to_s
55
59
  end
56
60
 
57
- # The advertised name for a base tool: the configured prefix followed by the base.
58
- def prefixed(base_name)
59
- "#{prefix}#{base_name}"
60
- end
61
-
62
61
  # Recovers the base tool name from an advertised name by stripping the configured
63
62
  # prefix, or nil when the name does not carry the (non-empty) prefix.
64
63
  def base_name_for(name)
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A `tool_provider` serving exactly one bespoke authority tool alongside the
4
+ # generic Registry-backed ones — the shape every host with a single custom tool
5
+ # would otherwise hand-roll. Used automatically by the composed default
6
+ # provider for bare tool classes in `config.extra_tool_providers`.
7
+ #
8
+ # The tool must expose `.definition` (the tools/list entry) and `.tool_name`,
9
+ # and itself satisfy the dispatcher's tool contract (`required_permissions_scope`
10
+ # + `call`) — e.g. a class built on McpToolkit::Tools::AuthorityBase. It is
11
+ # advertised unconditionally; its scope/authorization gates are enforced at
12
+ # call time (by the dispatcher and the tool itself).
13
+ class McpToolkit::Authority::SingleToolProvider
14
+ def initialize(tool)
15
+ @tool = tool
16
+ end
17
+
18
+ def tool_definitions(_context)
19
+ [@tool.definition]
20
+ end
21
+
22
+ def find(name)
23
+ name.to_s == @tool.tool_name ? @tool : nil
24
+ end
25
+ end
@@ -43,9 +43,26 @@ class McpToolkit::Authority::Tools::Base
43
43
  end
44
44
 
45
45
  # The static tool definition returned by the provider's `tool_definitions`
46
- # (part of `tools/list`). Generic and context-independent.
47
- def definition
48
- { name: tool_name, description: _description, inputSchema: input_schema }
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
49
66
  end
50
67
  end
51
68
 
@@ -119,4 +136,15 @@ class McpToolkit::Authority::Tools::Base
119
136
  rescue McpToolkit::Errors::InvalidParams => e
120
137
  raise McpToolkit::Protocol::InvalidParams, e.message
121
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
122
150
  end
@@ -45,7 +45,8 @@ class McpToolkit::Authority::Tools::Get < McpToolkit::Authority::Tools::Base
45
45
  }
46
46
  )
47
47
 
48
- def call(context:, resource: nil, id: nil, fields: nil, **_args)
48
+ def call(context:, resource: nil, id: nil, fields: nil, **extra)
49
+ reject_unknown_arguments!(extra.except(:account_id))
49
50
  descriptor = resolve_descriptor(resource)
50
51
  ensure_resource_accessible!(descriptor, context)
51
52
  ensure_scope!(descriptor, context)
@@ -7,6 +7,35 @@
7
7
  # custom filters, pagination, and sparse fieldsets are all handled by the reused
8
8
  # McpToolkit::ListExecutor.
9
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
+
10
39
  tool_name "list"
11
40
  description <<~DESC.strip
12
41
  Fetch a paginated list of records from a read-only resource. Pass the resource name as
@@ -23,6 +52,22 @@ class McpToolkit::Authority::Tools::List < McpToolkit::Authority::Tools::Base
23
52
  - filter: an object of { <key>: <value> } filters, applied ON TOP of the account scope
24
53
  (they can only narrow, never widen). Each resource advertises its available filter keys
25
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> }.
26
71
 
27
72
  Sparse fieldset:
28
73
  - fields: names of the attributes and/or relationships to include in each record, as an
@@ -67,7 +112,10 @@ class McpToolkit::Authority::Tools::List < McpToolkit::Authority::Tools::Base
67
112
  "field. Include \"id\" if you need it. Unknown names are rejected."
68
113
  }
69
114
  },
70
- required: ["resource"]
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
71
119
  }
72
120
  )
73
121
 
@@ -16,7 +16,14 @@ class McpToolkit::Authority::Tools::ResourceSchema < McpToolkit::Authority::Tool
16
16
  - relationships: associated resources emitted in the record's `links`; each names the
17
17
  `target_resource` it resolves to (callable via `list`/`get`)
18
18
  - standard_filters: ids, updated_since, limit, offset (accepted by the `list` tool)
19
- - filters: the per-attribute equality filter keys the `list` tool accepts
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.
20
27
  The `attributes` and `relationships` names are also the valid values for the `fields` sparse
21
28
  fieldset argument on `get` / `list`. Call this before `list` to learn a resource's shape.
22
29
  DESC
@@ -34,7 +41,8 @@ class McpToolkit::Authority::Tools::ResourceSchema < McpToolkit::Authority::Tool
34
41
  }
35
42
  )
36
43
 
37
- def call(context:, resource: nil, **_args)
44
+ def call(context:, resource: nil, **extra)
45
+ reject_unknown_arguments!(extra.except(:account_id))
38
46
  descriptor = resolve_descriptor(resource)
39
47
  ensure_resource_accessible!(descriptor, context)
40
48
  ensure_scope!(descriptor, context)
@@ -11,15 +11,23 @@ class McpToolkit::Authority::Tools::Resources < McpToolkit::Authority::Tools::Ba
11
11
  tool_name "resources"
12
12
  description <<~DESC.strip
13
13
  List all read-only resources available via the `list` and `get` tools. Returns each
14
- resource's name and a short description. Call this once at the start of a session to learn
15
- what exists, then use `resource_schema` for a specific resource's attributes and
16
- relationships.
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.
17
18
  DESC
18
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.
19
22
  def call(context:, **_args)
20
23
  {
21
24
  resources: visible_resources(context).map do |resource|
22
- { name: resource.name, description: resource.description }
25
+ {
26
+ name: resource.name,
27
+ description: resource.description,
28
+ filterable: filterable?(resource),
29
+ note: resource.note
30
+ }.compact
23
31
  end
24
32
  }
25
33
  end
@@ -30,4 +38,18 @@ class McpToolkit::Authority::Tools::Resources < McpToolkit::Authority::Tools::Ba
30
38
  def visible_resources(context)
31
39
  registry.resources.reject { |resource| resource.superusers_only? && !context.superuser? }
32
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
33
55
  end