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
@@ -7,24 +7,31 @@
7
7
  # Cache-backed (rather than the gem's in-process StreamableHTTPTransport) so
8
8
  # sessions survive across Puma workers and interoperate with a gateway's client.
9
9
  # The cache store + TTL come from McpToolkit.config.
10
+ #
11
+ # A session carries an opaque `data` hash the transport can attach at creation
12
+ # (e.g. `{ token_id: ... }`) so an AUTHORITY can bind a session to a token id —
13
+ # the property that lets a revoked token kill an in-flight session. The gem does
14
+ # NOT interpret `data` (it never re-resolves a token; that's the consumer's auth
15
+ # concern): it stores it, round-trips it, and exposes it via `#data`.
10
16
  class McpToolkit::Session
11
17
  CACHE_KEY_PREFIX = "mcp_toolkit:session:"
12
18
 
13
- def self.create!(config: McpToolkit.config)
19
+ def self.create!(data: {}, config: McpToolkit.config)
14
20
  id = SecureRandom.uuid
15
- config.cache_store.write(cache_key(id), { created_at: Time.now.to_i }, expires_in: config.session_ttl)
16
- new(id:)
21
+ config.cache_store.write(cache_key(id), { created_at: Time.now.to_i, data: }, expires_in: config.session_ttl)
22
+ new(id:, data:)
17
23
  end
18
24
 
19
25
  def self.find(id, config: McpToolkit.config)
20
26
  return nil if id.to_s.empty?
21
27
 
22
- data = config.cache_store.read(cache_key(id))
23
- return nil unless data
28
+ stored = config.cache_store.read(cache_key(id))
29
+ return nil unless stored
24
30
 
25
- # Sliding expiry: bump TTL on every successful lookup.
26
- config.cache_store.write(cache_key(id), data, expires_in: config.session_ttl)
27
- new(id:)
31
+ # Sliding expiry: bump TTL on every successful lookup, re-writing the row
32
+ # untouched.
33
+ config.cache_store.write(cache_key(id), stored, expires_in: config.session_ttl)
34
+ new(id:, data: stored[:data] || {})
28
35
  end
29
36
 
30
37
  def self.delete(id, config: McpToolkit.config)
@@ -38,9 +45,10 @@ class McpToolkit::Session
38
45
  end
39
46
  private_class_method :cache_key
40
47
 
41
- attr_reader :id
48
+ attr_reader :id, :data
42
49
 
43
- def initialize(id:)
50
+ def initialize(id:, data: {})
44
51
  @id = id
52
+ @data = data || {}
45
53
  end
46
54
  end
@@ -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
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Optional base class for a HOST's own tools served by the authority dispatcher
4
+ # (McpToolkit::Dispatcher). A host tool MAY subclass this, or may be any object
5
+ # satisfying the duck-typed tool contract the dispatcher calls:
6
+ #
7
+ # tool.required_permissions_scope -> String | nil (gem's scope gate)
8
+ # tool.call(context:, **arguments) -> Hash | String (gem wraps into
9
+ # { content: [...] })
10
+ #
11
+ # The dispatcher treats the object returned by `provider.find(name)` as the tool;
12
+ # an AuthorityBase SUBCLASS satisfies the contract as CLASS methods (the class
13
+ # `.call` instantiates, runs, and error-maps a single invocation).
14
+ #
15
+ # What this base adds over hand-rolling the contract:
16
+ # * a class DSL (`tool_name` / `description` / `input_schema` /
17
+ # `required_permissions_scope` / `definition`) mirroring the tool-definition
18
+ # shape `tools/list` returns;
19
+ # * per-request accessors (`account` / `principal` / `bearer_token` /
20
+ # `superuser?`) read from the injected Authority::Context;
21
+ # * `ensure_resource_accessible!` to gate a superuser-only resource;
22
+ # * error mapping — an ArgumentError (e.g. a missing required kwarg) becomes an
23
+ # InvalidParams, any other StandardError an InternalError, while a
24
+ # deliberately-raised McpToolkit::Protocol::Error passes through with its own
25
+ # code.
26
+ #
27
+ # The gem NEVER references a host's API layer, serializers, or resource catalog —
28
+ # all of that lives behind the host's `#call`.
29
+ class McpToolkit::Tools::AuthorityBase
30
+ class << self
31
+ attr_reader :_tool_name, :_description, :_input_schema
32
+
33
+ def tool_name(name = nil)
34
+ if name
35
+ @_tool_name = name.to_s
36
+ else
37
+ @_tool_name || self.name.to_s.demodulize.underscore.gsub(/_tool$/, "")
38
+ end
39
+ end
40
+
41
+ def description(desc = nil)
42
+ @_description = desc if desc
43
+ @_description
44
+ end
45
+
46
+ def input_schema(&block)
47
+ @_input_schema = yield if block
48
+ @_input_schema || { type: "object", properties: {} }
49
+ end
50
+
51
+ # OAuth-style scope (`<app>__<action>`) a token must carry to call this tool,
52
+ # enforced by the dispatcher before the tool runs. Defaults to nil (no scope
53
+ # required). NOT inherited — a subclass that doesn't declare its own scope is
54
+ # unscoped, even if an ancestor declared one.
55
+ def required_permissions_scope(scope = nil)
56
+ @_required_permissions_scope = scope.to_s if scope
57
+ @_required_permissions_scope
58
+ end
59
+
60
+ def definition
61
+ {
62
+ name: tool_name,
63
+ description: _description,
64
+ inputSchema: _input_schema || { type: "object", properties: {} }
65
+ }
66
+ end
67
+
68
+ # The dispatcher's entry point: build an instance bound to this request's
69
+ # context and run it, mapping tool-level errors to protocol errors.
70
+ def call(context:, **arguments)
71
+ new(context:).execute(**arguments)
72
+ end
73
+ end
74
+
75
+ attr_reader :context
76
+
77
+ def initialize(context:)
78
+ @context = context
79
+ end
80
+
81
+ def account
82
+ context.account
83
+ end
84
+
85
+ def principal
86
+ context.principal
87
+ end
88
+
89
+ def bearer_token
90
+ context.bearer_token
91
+ end
92
+
93
+ # Whether the caller is a superuser, per the Context (which duck-types it off
94
+ # the principal). Used to gate resources/tools that expose cross-tenant data.
95
+ def superuser?
96
+ context.superuser?
97
+ end
98
+
99
+ # Guards a resource flagged `superusers_only?`: a non-superuser caller is
100
+ # refused. No-op for unrestricted resources.
101
+ def ensure_resource_accessible!(resource)
102
+ return unless resource.superusers_only?
103
+ return if superuser?
104
+
105
+ raise McpToolkit::Protocol::InvalidRequest, "#{resource.name} is restricted to superuser (user-scoped) MCP tokens"
106
+ end
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
+
117
+ # Runs the tool's business logic (the subclass's `#call`) with error mapping.
118
+ # Arrives with symbol-keyed arguments from the dispatcher.
119
+ def execute(**arguments)
120
+ call(**arguments)
121
+ rescue McpToolkit::Protocol::Error
122
+ # A deliberately-raised protocol error carries its own JSON-RPC code
123
+ # (e.g. InvalidParams); let it bubble untouched so the client sees it.
124
+ raise
125
+ rescue ArgumentError => e
126
+ raise McpToolkit::Protocol::InvalidParams, e.message if ARGUMENT_BINDING_ERROR_MESSAGE.match?(e.message)
127
+
128
+ raise sanitized_internal_error(e)
129
+ rescue StandardError => e
130
+ raise sanitized_internal_error(e)
131
+ end
132
+
133
+ # The subclass implements its business logic here, receiving the tool arguments
134
+ # as keywords and returning a Hash or String.
135
+ def call(**_arguments)
136
+ raise NotImplementedError, "#{self.class} must implement #call"
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
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
- text_response(yield)
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 equality filters:
19
- - filter: an object of { <key>: <value> } exact-match filters, applied ON TOP of the
20
- account scope (they can only narrow, never widen). Each resource advertises its
21
- available filter keys via `resource_schema` (the `filters` array). Unknown keys are
22
- rejected.
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 exact-match equality filters, e.g. { \"booking_id\": 42 }. " \
54
- "See a resource's `resource_schema` `filters` for the keys it accepts.",
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` and a value `format` hint
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)) do
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 and a short description. Call this once at the start of a session to learn
9
- what exists, then use `resource_schema` for a specific resource's attributes and
10
- relationships.
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, required_scope: config.registry.default_required_permissions_scope) do
20
+ with_authentication(server_context,
21
+ required_scope: config.registry.default_required_permissions_scope) do |introspection|
20
22
  {
21
- resources: config.registry.resources.map do |resource|
22
- { name: resource.name, description: resource.description }
23
- end
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
@@ -5,7 +5,7 @@ require "logger"
5
5
  # The MCP Streamable-HTTP transport, provided as an includable concern. An
6
6
  # app's controller includes this to get the full transport with no per-app code:
7
7
  #
8
- # class Mcp::ServerController < ApplicationController
8
+ # class McpController < ApplicationController
9
9
  # include McpToolkit::Transport::ControllerMethods
10
10
  # end
11
11
  #
@@ -44,7 +44,7 @@ require "logger"
44
44
  #
45
45
  # CSRF: the concern disables forgery protection (this is a token-authenticated
46
46
  # JSON API). Inherit from ActionController::Base (not ::API) if your app's
47
- # controller stack needs helper_method, as bsa-notifications does.
47
+ # controller stack needs helper_method (as some host controller stacks require).
48
48
  module McpToolkit::Transport::ControllerMethods
49
49
  extend ActiveSupport::Concern
50
50
 
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Generic, api-agnostic usage metering for the AUTHORITY transport.
4
+ #
5
+ # Wired to the authority controller's billing hooks purely through config, so a
6
+ # host meters MCP traffic WITHOUT subclassing or customizing the controller:
7
+ #
8
+ # meter = McpToolkit::UsageMetering::Recorder.new(
9
+ # event_builder: ->(request_data:, params:, arguments:, scrubbed_arguments:, account:, principal:) { {...} },
10
+ # sink: ->(events) { MyLedger.insert_all(events) },
11
+ # parameter_filter: ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters),
12
+ # logger: Rails.logger,
13
+ # error_reporter: ->(e) { Sentry.capture_exception(e) }
14
+ # )
15
+ # config.usage_recorder = meter.method(:record) # per JSON-RPC call
16
+ # config.usage_flusher = meter.method(:flush) # after the response
17
+ #
18
+ # One event is accumulated per BILLABLE JSON-RPC call (default: `tools/call`) and
19
+ # all of a request's events are flushed together after the response. Because the
20
+ # authority transport re-resolves the account per batch element and calls `record`
21
+ # per element, a mixed-account batch meters each call against its own account.
22
+ #
23
+ # Two invariants:
24
+ # * Metering NEVER affects the MCP response — every error here is logged (via
25
+ # `logger`) and reported (via `error_reporter`), then swallowed.
26
+ # * The raw arguments are scrubbed through `parameter_filter` BEFORE they reach
27
+ # the event, so a filtered key (e.g. a token) never leaves this object.
28
+ #
29
+ # The `event_builder` returns ONE ledger row's attributes (a Hash) for a call, or
30
+ # nil to skip it; the gem stays app-agnostic by never naming the row's columns. The
31
+ # `sink` persists the accumulated array in one shot.
32
+ #
33
+ # Per-request state (the accumulation buffer) lives on the Rack request env, so the
34
+ # Recorder itself is stateless and safe to share across requests/threads.
35
+ class McpToolkit::UsageMetering::Recorder
36
+ DEFAULT_BILLABLE_METHODS = %w[tools/call].freeze
37
+ BUFFER_ENV_KEY = "mcp_toolkit.usage_events"
38
+
39
+ def initialize(event_builder:, sink:, parameter_filter: nil,
40
+ billable_methods: DEFAULT_BILLABLE_METHODS, logger: nil, error_reporter: nil)
41
+ @event_builder = event_builder
42
+ @sink = sink
43
+ @parameter_filter = parameter_filter
44
+ @billable_methods = billable_methods
45
+ @logger = logger
46
+ @error_reporter = error_reporter
47
+ end
48
+
49
+ # `config.usage_recorder` target. Accumulates one event for a billable call onto
50
+ # the current request's buffer. Non-billable methods (ping, initialize,
51
+ # tools/list, ...) are ignored; a nil event from the builder is skipped.
52
+ def record(request_data:, account:, principal:, controller:)
53
+ return unless request_data.is_a?(Hash)
54
+ return unless @billable_methods.include?(request_data["method"])
55
+
56
+ params = request_data["params"].to_h
57
+ arguments = params["arguments"].to_h
58
+ event = @event_builder.call(
59
+ request_data:, params:, arguments:,
60
+ scrubbed_arguments: scrub(arguments), account:, principal:
61
+ )
62
+ buffer_for(controller) << event unless event.nil?
63
+ rescue StandardError => e
64
+ report("failed to accumulate event", e)
65
+ end
66
+
67
+ # `config.usage_flusher` target. Persists the request's accumulated events via the
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.
71
+ def flush(controller:)
72
+ events = buffer_for(controller)
73
+ return if events.empty?
74
+
75
+ @sink.call(events)
76
+ rescue StandardError => e
77
+ flush_individually(events, e)
78
+ end
79
+
80
+ private
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
+
106
+ def scrub(arguments)
107
+ hash = arguments.to_h
108
+ @parameter_filter ? @parameter_filter.filter(hash) : hash
109
+ end
110
+
111
+ # Per-request accumulation buffer, stored on the Rack env so the Recorder holds
112
+ # no per-request state of its own (thread-safe to share across requests).
113
+ def buffer_for(controller)
114
+ controller.request.env[BUFFER_ENV_KEY] ||= []
115
+ end
116
+
117
+ def report(message, error)
118
+ @logger&.warn("MCP usage tracking: #{message}: #{error.message}")
119
+ @error_reporter&.call(error)
120
+ end
121
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module McpToolkit
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/mcp_toolkit.rb CHANGED
@@ -15,9 +15,12 @@ require "zeitwerk"
15
15
  # active_support/concern - Transport::ControllerMethods is an includable concern
16
16
  # active_support/cache - the default MemoryStore cache_store
17
17
  #
18
- # `faraday` is the one exception: it is required alongside its sole owner,
19
- # Auth::AuthorityServerClient, which is the only object that builds an HTTP
20
- # connection.
18
+ # Two third-party libs are the exception to the centralize-here rule: each is
19
+ # required alongside its owner file rather than up front.
20
+ # faraday - the HTTP client, required by the objects that build a connection
21
+ # (Auth::AuthorityServerClient and Gateway::Client).
22
+ # concurrent - concurrent-ruby's futures, required by its sole owner
23
+ # Gateway::Aggregator (which pulls upstreams in parallel).
21
24
  require "json"
22
25
  require "digest"
23
26
  require "time"
@@ -63,8 +66,18 @@ loader.ignore("#{__dir__}/mcp_toolkit/version.rb")
63
66
  # under `app/controllers` (outside this lib-rooted loader), so it needs no ignore;
64
67
  # it is loaded by Rails' autoloader via the engine when mounted.
65
68
  loader.ignore("#{__dir__}/mcp_toolkit/engine.rb")
69
+ # `engine_controllers.rb` reopens `McpToolkit` to add the lazy `parent_controller`
70
+ # builder + `const_missing` (it defines module methods, not a file-named
71
+ # constant), so it is required explicitly below rather than autoloaded.
72
+ loader.ignore("#{__dir__}/mcp_toolkit/engine_controllers.rb")
66
73
  loader.setup
67
74
 
75
+ # The lazy `parent_controller` builder (McpToolkit.build_engine_controllers! /
76
+ # reset_engine_controllers! / const_missing). Required unconditionally (it names no
77
+ # Rails constant at load time); it only builds controllers when one is referenced,
78
+ # which happens only under Rails.
79
+ require_relative "mcp_toolkit/engine_controllers"
80
+
68
81
  # The toolkit for building account-scoped, read-only MCP servers on top of the
69
82
  # official `mcp` gem. See README.md for the satellite + authority quickstarts.
70
83
  #