mcp_toolkit 0.3.0 → 0.4.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +201 -2
  3. data/README.md +315 -34
  4. data/config/routes.rb +19 -4
  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 +408 -0
  9. data/lib/mcp_toolkit/authority/registry_tool_provider.rb +70 -0
  10. data/lib/mcp_toolkit/authority/token.rb +124 -0
  11. data/lib/mcp_toolkit/authority/tools/base.rb +122 -0
  12. data/lib/mcp_toolkit/authority/tools/get.rb +58 -0
  13. data/lib/mcp_toolkit/authority/tools/list.rb +84 -0
  14. data/lib/mcp_toolkit/authority/tools/resource_schema.rb +44 -0
  15. data/lib/mcp_toolkit/authority/tools/resources.rb +33 -0
  16. data/lib/mcp_toolkit/authority.rb +24 -0
  17. data/lib/mcp_toolkit/configuration.rb +205 -2
  18. data/lib/mcp_toolkit/dispatcher.rb +234 -0
  19. data/lib/mcp_toolkit/engine.rb +22 -7
  20. data/lib/mcp_toolkit/engine_controllers.rb +116 -0
  21. data/lib/mcp_toolkit/gateway/aggregator.rb +122 -0
  22. data/lib/mcp_toolkit/gateway/client.rb +305 -0
  23. data/lib/mcp_toolkit/gateway/proxy.rb +70 -0
  24. data/lib/mcp_toolkit/gateway/unknown_upstream.rb +8 -0
  25. data/lib/mcp_toolkit/gateway/upstream_call_error.rb +23 -0
  26. data/lib/mcp_toolkit/gateway/upstream_registry.rb +79 -0
  27. data/lib/mcp_toolkit/list_executor.rb +17 -0
  28. data/lib/mcp_toolkit/protocol.rb +106 -0
  29. data/lib/mcp_toolkit/rate_limiter.rb +73 -0
  30. data/lib/mcp_toolkit/registry.rb +30 -2
  31. data/lib/mcp_toolkit/resource.rb +125 -7
  32. data/lib/mcp_toolkit/resource_schema.rb +14 -1
  33. data/lib/mcp_toolkit/serializer/base.rb +2 -2
  34. data/lib/mcp_toolkit/session.rb +17 -9
  35. data/lib/mcp_toolkit/tools/authority_base.rb +127 -0
  36. data/lib/mcp_toolkit/transport/controller_methods.rb +2 -2
  37. data/lib/mcp_toolkit/usage_metering/recorder.rb +95 -0
  38. data/lib/mcp_toolkit/version.rb +1 -1
  39. data/lib/mcp_toolkit.rb +16 -3
  40. metadata +38 -2
  41. data/app/controllers/mcp_toolkit/server_controller.rb +0 -19
@@ -0,0 +1,122 @@
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.
47
+ def definition
48
+ { name: tool_name, description: _description, inputSchema: input_schema }
49
+ end
50
+ end
51
+
52
+ attr_reader :config
53
+
54
+ def initialize(config:)
55
+ @config = config
56
+ end
57
+
58
+ # The dispatcher's central scope gate reads this; nil = no STATIC scope. The
59
+ # real, per-resource scope is enforced dynamically in #ensure_scope! (the scope
60
+ # depends on the `resource` argument, unknown until #call).
61
+ def required_permissions_scope
62
+ nil
63
+ end
64
+
65
+ private
66
+
67
+ def registry
68
+ config.registry
69
+ end
70
+
71
+ # Resolves the `resource` argument to a registered descriptor, raising the
72
+ # protocol InvalidParams (=> JSON-RPC -32602) for a blank or unknown name so the
73
+ # dispatcher renders a clean top-level error.
74
+ def resolve_descriptor(name)
75
+ raise McpToolkit::Protocol::InvalidParams, "resource is required" if name.to_s.strip.empty?
76
+
77
+ registry.fetch(name)
78
+ rescue McpToolkit::Registry::UnknownResource => e
79
+ raise McpToolkit::Protocol::InvalidParams, e.message
80
+ end
81
+
82
+ # Refuses a superuser-only resource for a non-superuser caller (get / list /
83
+ # resource_schema). `resources` HIDES such resources instead — see that tool.
84
+ def ensure_resource_accessible!(descriptor, context)
85
+ return unless descriptor.superusers_only?
86
+ return if context.superuser?
87
+
88
+ raise McpToolkit::Protocol::InvalidRequest,
89
+ "#{descriptor.name} is restricted to superuser (user-scoped) MCP tokens"
90
+ end
91
+
92
+ # Enforces the resource's effective required scope against the principal. Blank
93
+ # scope => no check. Mirrors the dispatcher's central gate error shape
94
+ # (InvalidRequest), keeping scope refusals byte-consistent across host tools.
95
+ def ensure_scope!(descriptor, context)
96
+ required = registry.required_scope_for(descriptor)
97
+ return if required.to_s.empty?
98
+ return if context.principal&.authorized_for_scope?(required)
99
+
100
+ raise McpToolkit::Protocol::InvalidRequest, "This token lacks the #{required.inspect} scope"
101
+ end
102
+
103
+ # get / list read tenant data, so they REQUIRE a resolved account: a superuser
104
+ # token that selected none would otherwise reach `scope.call(nil)` and leak
105
+ # across tenants. resource_schema / resources (shape only) do not call this.
106
+ def ensure_account!(context)
107
+ return if context.account
108
+
109
+ raise McpToolkit::Protocol::InvalidParams,
110
+ "an account must be selected (pass account_id) to read this resource"
111
+ end
112
+
113
+ # Runs an executor, translating a data-layer McpToolkit::Errors::InvalidParams
114
+ # (bad id, unknown filter/field key, ...) into the protocol InvalidParams the
115
+ # dispatcher renders as JSON-RPC -32602 (rather than letting it fall through to
116
+ # the dispatcher's generic -32603 internal-error mapping).
117
+ def run_executor
118
+ yield
119
+ rescue McpToolkit::Errors::InvalidParams => e
120
+ raise McpToolkit::Protocol::InvalidParams, e.message
121
+ end
122
+ end
@@ -0,0 +1,58 @@
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, **_args)
49
+ descriptor = resolve_descriptor(resource)
50
+ ensure_resource_accessible!(descriptor, context)
51
+ ensure_scope!(descriptor, context)
52
+ ensure_account!(context)
53
+
54
+ run_executor do
55
+ McpToolkit::GetExecutor.call(resource: descriptor, scope_root: context.account, id:, fields:)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,84 @@
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
+ tool_name "list"
11
+ description <<~DESC.strip
12
+ Fetch a paginated list of records from a read-only resource. Pass the resource name as
13
+ `resource`. Use the `resources` tool to discover resources and `resource_schema` to learn a
14
+ resource's shape.
15
+
16
+ Standard filters:
17
+ - ids: comma-separated list of IDs to fetch
18
+ - updated_since: ISO 8601 timestamp; only records updated after this time
19
+ - limit: page size (default 25, max 100)
20
+ - offset: pagination offset (default 0)
21
+
22
+ Per-attribute filters:
23
+ - filter: an object of { <key>: <value> } filters, applied ON TOP of the account scope
24
+ (they can only narrow, never widen). Each resource advertises its available filter keys
25
+ and operators via `resource_schema`. Unknown keys are rejected.
26
+
27
+ Sparse fieldset:
28
+ - fields: names of the attributes and/or relationships to include in each record, as an
29
+ array or a comma-separated string. Omit to return every field. Include "id" if you need
30
+ it. Valid names come from a resource's `resource_schema`; unknown names are rejected.
31
+
32
+ For tokens that span multiple accounts (superuser), pass `account_id` to pin the active
33
+ account; account-scoped tokens may omit it. The response shape is
34
+ { "<resource>": [...], "meta": { total_count, limit, offset } }.
35
+ DESC
36
+
37
+ input_schema(
38
+ {
39
+ type: "object",
40
+ properties: {
41
+ resource: {
42
+ type: "string",
43
+ description: "Resource name (use the `resources` tool to discover valid values)"
44
+ },
45
+ account_id: {
46
+ type: "integer",
47
+ description: "Account to operate on. Required for superuser tokens; ignored otherwise."
48
+ },
49
+ ids: { type: "string", description: "Comma-separated list of IDs to fetch" },
50
+ updated_since: {
51
+ type: "string",
52
+ description: "ISO 8601 timestamp; only records updated after this time"
53
+ },
54
+ filter: {
55
+ type: "object",
56
+ description: "Per-attribute filters, e.g. { \"booking_id\": 42 }. See a resource's " \
57
+ "`resource_schema` `filters` for the keys and operators it accepts.",
58
+ additionalProperties: true
59
+ },
60
+ limit: { type: "integer", description: "Page size (default 25, max 100)" },
61
+ offset: { type: "integer", description: "Pagination offset (default 0)" },
62
+ fields: {
63
+ type: %w[array string],
64
+ items: { type: "string" },
65
+ description: "Sparse fieldset — names of attributes and/or relationships to include in " \
66
+ "each record, as an array or a comma-separated string. Omit to return every " \
67
+ "field. Include \"id\" if you need it. Unknown names are rejected."
68
+ }
69
+ },
70
+ required: ["resource"]
71
+ }
72
+ )
73
+
74
+ def call(context:, resource: nil, **params)
75
+ descriptor = resolve_descriptor(resource)
76
+ ensure_resource_accessible!(descriptor, context)
77
+ ensure_scope!(descriptor, context)
78
+ ensure_account!(context)
79
+
80
+ run_executor do
81
+ McpToolkit::ListExecutor.call(resource: descriptor, scope_root: context.account, params:)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,44 @@
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 filter keys the `list` tool accepts
20
+ The `attributes` and `relationships` names are also the valid values for the `fields` sparse
21
+ fieldset argument on `get` / `list`. Call this before `list` to learn a resource's shape.
22
+ DESC
23
+
24
+ input_schema(
25
+ {
26
+ type: "object",
27
+ properties: {
28
+ resource: {
29
+ type: "string",
30
+ description: "Resource name (use the `resources` tool to discover valid values)"
31
+ }
32
+ },
33
+ required: ["resource"]
34
+ }
35
+ )
36
+
37
+ def call(context:, resource: nil, **_args)
38
+ descriptor = resolve_descriptor(resource)
39
+ ensure_resource_accessible!(descriptor, context)
40
+ ensure_scope!(descriptor, context)
41
+
42
+ McpToolkit::ResourceSchema.call(descriptor, registry:)
43
+ end
44
+ end
@@ -0,0 +1,33 @@
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 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.
17
+ DESC
18
+
19
+ def call(context:, **_args)
20
+ {
21
+ resources: visible_resources(context).map do |resource|
22
+ { name: resource.name, description: resource.description }
23
+ end
24
+ }
25
+ end
26
+
27
+ private
28
+
29
+ # Superuser-only resources are hidden from a non-superuser caller.
30
+ def visible_resources(context)
31
+ registry.resources.reject { |resource| resource.superusers_only? && !context.superuser? }
32
+ end
33
+ 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
@@ -25,6 +25,24 @@ class McpToolkit::Configuration
25
25
  # @return [String, nil] human-readable `instructions` returned on `initialize`.
26
26
  attr_accessor :server_instructions
27
27
 
28
+ # --- gateway client identity (identity split) ------------------------------
29
+
30
+ # The `clientInfo` an app presents when it acts as a GATEWAY talking to its
31
+ # upstream MCP servers (McpToolkit::Gateway::Client's handshake). Split from the
32
+ # SERVER identity (`server_name`/`server_version`, advertised to the app's OWN
33
+ # callers) so an authority can present its real server identity downstream while
34
+ # keeping its upstream handshake byte-identical to a prior deployment. Both
35
+ # default to the server identity, so a satellite/gateway that doesn't care sets
36
+ # nothing.
37
+ #
38
+ # c.server_name = "acme-mcp" # advertised to our callers
39
+ # c.gateway_client_name = "acme-mcp-gateway" # presented to our upstreams
40
+ #
41
+ # @return [String] gateway handshake client name (defaults to `server_name`).
42
+ attr_writer :gateway_client_name
43
+ # @return [String] gateway handshake client version (defaults to `server_version`).
44
+ attr_writer :gateway_client_version
45
+
28
46
  # --- serialization ---------------------------------------------------------
29
47
 
30
48
  # The DEFAULT serializer base class. A `Resource` registration that does not
@@ -81,10 +99,10 @@ class McpToolkit::Configuration
81
99
 
82
100
  # Looks up + verifies a plaintext bearer token locally, returning a token
83
101
  # object (duck-typed, see below) or nil. This is the authority's
84
- # `McpToken.authenticate(plaintext)` equivalent. Required for the :authority
102
+ # `AccessToken.authenticate(plaintext)` equivalent. Required for the :authority
85
103
  # role; unused by a pure satellite.
86
104
  #
87
- # c.token_authenticator = ->(plaintext) { McpToken.authenticate(plaintext) }
105
+ # c.token_authenticator = ->(plaintext) { AccessToken.authenticate(plaintext) }
88
106
  #
89
107
  # The returned token object must respond to the methods
90
108
  # `McpToolkit::Auth::Authority#introspection_payload` reads (see that module for
@@ -108,6 +126,39 @@ class McpToolkit::Configuration
108
126
  # @return [Integer] session sliding-TTL in seconds.
109
127
  attr_accessor :session_ttl
110
128
 
129
+ # --- rate limiting ---------------------------------------------------------
130
+
131
+ # The built-in per-principal request cap enforced by the authority transport
132
+ # (McpToolkit::Authority::ControllerMethods#mcp_rate_limit!), counted against
133
+ # `cache_store` via McpToolkit::RateLimiter. nil (the default) DISABLES rate
134
+ # limiting entirely, so a pure host is unaffected until it opts in. Set an
135
+ # Integer to cap each principal to that many requests per `rate_limit_window`.
136
+ # The default `mcp_rate_limit!` reads this through the overridable
137
+ # `mcp_rate_limit_max_requests` hook, so a host that keeps the cap in its own
138
+ # constant/model overrides that hook rather than this value.
139
+ #
140
+ # @return [Integer, nil]
141
+ attr_accessor :rate_limit_max_requests
142
+
143
+ # The fixed rate-limit window, in seconds (default 3600 = 1 hour). Ignored
144
+ # while `rate_limit_max_requests` is nil.
145
+ #
146
+ # @return [Integer]
147
+ attr_accessor :rate_limit_window
148
+
149
+ # --- superuser (optional, first-class) -------------------------------------
150
+
151
+ # Optional resolver deciding whether a principal is a SUPERUSER — a cross-tenant
152
+ # caller that may reach `superusers_only!` resources. `->(principal) -> Boolean`.
153
+ # When set, McpToolkit::Authority::Context#superuser? calls it; when nil (the
154
+ # default) the context falls back to duck-typing `principal.superuser?` (false
155
+ # when the principal doesn't respond to it). Superuser is FULLY OPTIONAL: a host
156
+ # with no such concept leaves this nil and flags no `superusers_only!` resource,
157
+ # so no caller is ever a superuser.
158
+ #
159
+ # @return [#call, nil]
160
+ attr_accessor :superuser_resolver
161
+
111
162
  # --- filtering -------------------------------------------------------------
112
163
 
113
164
  # Escapes LIKE wildcards in `matches` / `does_not_match` filter values so they
@@ -124,6 +175,14 @@ class McpToolkit::Configuration
124
175
  # nil lets the gem negotiate (recommended). Set only to force an older spec.
125
176
  attr_accessor :protocol_version
126
177
 
178
+ # The protocol versions the hand-rolled AUTHORITY dispatcher
179
+ # (McpToolkit::Dispatcher) negotiates, newest first. `initialize` echoes the
180
+ # requested version when it is in this set, else the first (latest). Defaults to
181
+ # McpToolkit::Protocol::SUPPORTED_VERSIONS; override to pin a host's own set.
182
+ #
183
+ # @return [Array<String>]
184
+ attr_accessor :supported_protocol_versions
185
+
127
186
  # The parent class (as a String, resolved via `constantize`) of the
128
187
  # gem-provided McpToolkit::ServerController that McpToolkit::Engine mounts.
129
188
  # Doorkeeper-style indirection so a satellite mounting the engine can keep
@@ -151,12 +210,111 @@ class McpToolkit::Configuration
151
210
  # @return [McpToolkit::Registry]
152
211
  attr_accessor :registry
153
212
 
213
+ # --- gateway / upstreams ---------------------------------------------------
214
+
215
+ # @return [Integer] HTTP open/read timeout (s) for a gateway's calls to an
216
+ # upstream MCP server (McpToolkit::Gateway::Client).
217
+ attr_accessor :upstream_timeout
218
+ # @return [Integer] TTL (s) for an upstream's cached, namespaced tool list in
219
+ # McpToolkit::Gateway::Aggregator.
220
+ attr_accessor :upstream_list_ttl
221
+
222
+ # The registry of upstream MCP servers this gateway aggregates + proxies to.
223
+ # Each config carries its own (like `registry`), so it resets with a fresh
224
+ # config. Register via the `register_upstream` sugar below or directly on this
225
+ # instance. Empty unless the app registers upstreams, so a non-gateway app is
226
+ # unaffected.
227
+ #
228
+ # @return [McpToolkit::Gateway::UpstreamRegistry]
229
+ attr_reader :upstreams
230
+
231
+ # --- authority hooks -------------------------------------------------------
232
+ #
233
+ # Injection points for the AUTHORITY transport (McpToolkit::Authority::
234
+ # ControllerMethods) so a PURE host drives billing/tenancy from config without
235
+ # subclassing. A host whose logic touches its own models overrides the matching
236
+ # hook METHOD on its McpToolkit::Authority::ServerController subclass instead;
237
+ # then these stay nil. All default to nil (a no-op).
238
+
239
+ # OPTIONAL escape hatch that FULLY REPLACES the built-in limiter: a
240
+ # `->(controller:, principal:)` that renders + halts when over the limit (or
241
+ # sets rate-limit headers when under). When set, `mcp_rate_limit!` delegates to
242
+ # it and the built-in (`rate_limit_max_requests`) is skipped. nil (the default)
243
+ # means the built-in runs instead. Most hosts want the built-in; reach for this
244
+ # only when the counting itself must live in app code.
245
+ #
246
+ # @return [#call, nil]
247
+ attr_accessor :rate_limiter
248
+
249
+ # Records ONE usage event for a single JSON-RPC call (called per batch element).
250
+ # `->(request_data:, account:, principal:, controller:)`. MUST never affect the
251
+ # MCP response. nil = no metering.
252
+ #
253
+ # @return [#call, nil]
254
+ attr_accessor :usage_recorder
255
+
256
+ # Persists accumulated usage after the response (an after_action).
257
+ # `->(controller:)`. MUST never affect the MCP response. nil = no flush.
258
+ #
259
+ # @return [#call, nil]
260
+ attr_accessor :usage_flusher
261
+
262
+ # Builds the opaque payload bound to a session on `initialize`. `->(principal:)`
263
+ # returning a Hash (or nil for none). Lets a host bind e.g.
264
+ # `{ token_id: principal.id }` so a revoked token can kill an in-flight session,
265
+ # WITHOUT overriding the controller's `mcp_session_data`. nil (the default) =>
266
+ # an empty session payload.
267
+ #
268
+ # @return [#call, nil]
269
+ attr_accessor :session_data_builder
270
+
271
+ # The host's tool catalog — the api-agnostic seam. Duck-typed; the dispatcher
272
+ # calls:
273
+ #
274
+ # provider.tool_definitions(context) -> [{ name:, description:, inputSchema: }]
275
+ # provider.find(name) -> a tool object, or nil
276
+ #
277
+ # where a tool object responds to `#required_permissions_scope` (String|nil, the
278
+ # gem's scope gate) and `#call(context:, **arguments)` (returns Hash|String,
279
+ # which the gem wraps into `{ content: [{ type: "text", text: }] }`). See
280
+ # McpToolkit::Tools::AuthorityBase for a base that satisfies this. nil = the host
281
+ # contributes no own tools (a pure gateway).
282
+ #
283
+ # @return [#tool_definitions, #find, nil]
284
+ attr_accessor :tool_provider
285
+
286
+ # --- generic tool naming ---------------------------------------------------
287
+
288
+ # A prefix prepended to the four GENERIC, Registry-backed authority tool names
289
+ # (`resources`, `resource_schema`, `get`, `list`) served by
290
+ # McpToolkit::Authority::RegistryToolProvider. Lets a host NAMESPACE its generic
291
+ # tools — e.g. set `"foo_"` and they advertise (and resolve) as `foo_resources`,
292
+ # `foo_resource_schema`, `foo_get`, `foo_list` — so distinct MCP surfaces don't
293
+ # collide and existing clients keep a stable, host-chosen name. Empty by default,
294
+ # so the tools keep their bare base names. The prefix value is the host's; the gem
295
+ # names no app concept.
296
+ #
297
+ # @return [String]
298
+ attr_accessor :generic_tool_name_prefix
299
+
300
+ # --- diagnostics -----------------------------------------------------------
301
+
302
+ # Optional logger for gateway/session diagnostics. All call sites guard with
303
+ # `logger&.warn` / `logger&.error`, so nil (the default) silences them. A Rails
304
+ # host typically sets this to `Rails.logger`.
305
+ #
306
+ # @return [#warn, #error, nil]
307
+ attr_accessor :logger
308
+
154
309
  # Vendor-neutral defaults; apps override the auth wiring + identity as needed.
155
310
  def initialize
156
311
  @server_name = "mcp-server"
157
312
  @server_version = "1.0.0"
158
313
  @server_instructions = nil
159
314
 
315
+ @gateway_client_name = nil
316
+ @gateway_client_version = nil
317
+
160
318
  @serializer_base = nil # set lazily in #serializer_base to avoid load-order issues
161
319
 
162
320
  @auth_role = :satellite
@@ -174,11 +332,56 @@ class McpToolkit::Configuration
174
332
  @sql_sanitizer = McpToolkit::SqlSanitizer.new
175
333
 
176
334
  @protocol_version = nil
335
+ @supported_protocol_versions = McpToolkit::Protocol::SUPPORTED_VERSIONS
177
336
  @parent_controller = "ActionController::Base"
178
337
  @account_meta_key = "mcp-toolkit/account-id"
179
338
  @account_id_header = "X-MCP-Account-ID"
180
339
 
340
+ initialize_authority_hook_defaults
341
+ @generic_tool_name_prefix = ""
342
+
343
+ @upstream_timeout = 10
344
+ @upstream_list_ttl = 900 # 15 minutes
345
+ @logger = nil
346
+
181
347
  @registry = McpToolkit::Registry.new
348
+ @upstreams = McpToolkit::Gateway::UpstreamRegistry.new
349
+ end
350
+
351
+ # The authority transport's injection points all default to nil (a no-op): a
352
+ # pure satellite/gateway never touches them. `rate_limit_window` is the sole
353
+ # non-nil default (the window size only matters once a cap opts in).
354
+ def initialize_authority_hook_defaults
355
+ @rate_limiter = nil
356
+ @usage_recorder = nil
357
+ @usage_flusher = nil
358
+ @session_data_builder = nil
359
+ @tool_provider = nil
360
+ @rate_limit_max_requests = nil # nil = rate limiting disabled
361
+ @rate_limit_window = 3600 # 1 hour
362
+ @superuser_resolver = nil # nil = duck-type principal.superuser?
363
+ end
364
+
365
+ # Config sugar: register a gateway upstream. Delegates to `upstreams.register`,
366
+ # so a blank url is ignored (an unconfigured upstream is simply absent). Pass
367
+ # `public_tool_list: false` for an upstream whose tool list varies by caller
368
+ # privilege, to opt it out of the shared list cache.
369
+ #
370
+ # c.register_upstream(key: "notifications", url: ENV["NOTIFICATIONS_SERVER_URL"])
371
+ def register_upstream(key:, url:, public_tool_list: true)
372
+ upstreams.register(key:, url:, public_tool_list:)
373
+ end
374
+
375
+ # The gateway handshake client name, defaulting to the server identity when the
376
+ # host hasn't split it. Read (not stored) so a `server_name` change before the
377
+ # split is set still flows through.
378
+ def gateway_client_name
379
+ @gateway_client_name || server_name
380
+ end
381
+
382
+ # The gateway handshake client version, defaulting to the server version.
383
+ def gateway_client_version
384
+ @gateway_client_version || server_version
182
385
  end
183
386
 
184
387
  # The serializer base, lazily defaulting to the gem's bundled DSL base. Lazy so