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
data/README.md CHANGED
@@ -61,8 +61,8 @@ introspects each forwarded bearer token against the central app.
61
61
 
62
62
  ```ruby
63
63
  McpToolkit.configure do |c|
64
- c.server_name = "bsa-notifications-mcp"
65
- c.server_instructions = "Read-only access to this account's notifications domain."
64
+ c.server_name = "acme-mcp"
65
+ c.server_instructions = "Read-only access to this account's widgets domain."
66
66
 
67
67
  # --- satellite auth ---
68
68
  c.auth_role = :satellite
@@ -72,7 +72,7 @@ McpToolkit.configure do |c|
72
72
  # can override it per-resource (see below). Omit entirely for "no scope
73
73
  # required". Whether a scope is required is PER TOOL — there is no app-wide
74
74
  # permission flag.
75
- c.registry.default_required_permissions_scope "notifications__read"
75
+ c.registry.default_required_permissions_scope "widgets__read"
76
76
 
77
77
  # Map the central account id to this app's LOCAL scope root (an Account here).
78
78
  c.account_resolver = ->(synced_account_id) { Account.find_by(synced_id: synced_account_id) }
@@ -94,22 +94,22 @@ the resolved scope root — this is the single tenancy chokepoint:
94
94
  Rails.application.config.to_prepare do
95
95
  McpToolkit.registry.reset!
96
96
 
97
- McpToolkit.registry.register(:notifications) do
98
- model Notification
99
- serializer Mcp::NotificationSerializer # your serializer (see below)
100
- description "Email notification templates + their scheduling rules."
101
- scope(&:notifications) # account.notifications
97
+ McpToolkit.registry.register(:widgets) do
98
+ model Widget
99
+ serializer WidgetSerializer # your serializer (see below)
100
+ description "Widget templates + their scheduling rules."
101
+ scope(&:widgets) # account.widgets
102
102
  end
103
103
 
104
- McpToolkit.registry.register(:scheduled_notifications) do
105
- model ScheduledNotification
106
- serializer Mcp::ScheduledNotificationSerializer
107
- description "Scheduled mailings."
104
+ McpToolkit.registry.register(:scheduled_widgets) do
105
+ model ScheduledWidget
106
+ serializer ScheduledWidgetSerializer
107
+ description "Scheduled widget deliveries."
108
108
  # Expose a public filter key that maps to a synced storage column:
109
109
  filterable booking_id: :synced_booking_id
110
110
  # Override the registry default scope for just this resource (optional):
111
- required_permissions_scope "notifications__read"
112
- scope { |account| ScheduledNotification.where(synced_account_id: account.synced_id) }
111
+ required_permissions_scope "widgets__read"
112
+ scope { |account| ScheduledWidget.where(synced_account_id: account.synced_id) }
113
113
  end
114
114
  end
115
115
  ```
@@ -141,12 +141,12 @@ introspecting the forwarded token and scoped to the resolved account.
141
141
  The authority authenticates plaintext tokens locally and answers the
142
142
  introspection requests satellites send.
143
143
 
144
- **1. Configure** the local token lookup (your `McpToken.authenticate` equivalent):
144
+ **1. Configure** the local token lookup (your `AccessToken.authenticate` equivalent):
145
145
 
146
146
  ```ruby
147
147
  McpToolkit.configure do |c|
148
148
  c.auth_role = :authority
149
- c.token_authenticator = ->(plaintext) { McpToken.authenticate(plaintext) }
149
+ c.token_authenticator = ->(plaintext) { AccessToken.authenticate(plaintext) }
150
150
  c.cache_store = Rails.cache
151
151
  end
152
152
  ```
@@ -155,12 +155,12 @@ The token object your authenticator returns must respond to:
155
155
  `kind` (`:accounts_user` | `:user`), `account_id`, `account_ids`, `expires_at`
156
156
  (an `#iso8601`-able time or nil), and `scopes` (an array of `<app>__<action>`
157
157
  scopes; `[]` = no scopes). Optionally `touch_last_used!`. A typical app token
158
- model (e.g. `McpToken`) fits.
158
+ model (e.g. `AccessToken`) fits.
159
159
 
160
160
  **2. Expose the introspection endpoint** the satellites call:
161
161
 
162
162
  ```ruby
163
- class Mcp::TokensController < ActionController::API
163
+ class TokensController < ActionController::API
164
164
  def introspect
165
165
  token = McpToolkit::Auth::Authority.authenticate(extract_token)
166
166
  return render(json: McpToolkit::Auth::Authority.invalid_payload, status: :unauthorized) unless token
@@ -181,7 +181,7 @@ end
181
181
 
182
182
  ```ruby
183
183
  # config/routes.rb
184
- post "mcp/tokens/introspect", to: "mcp/tokens#introspect"
184
+ post "mcp/tokens/introspect", to: "tokens#introspect"
185
185
  ```
186
186
 
187
187
  The payload `introspection_payload` emits is exactly the contract the satellite's
@@ -226,13 +226,13 @@ end
226
226
  ### Using the bundled base
227
227
 
228
228
  ```ruby
229
- class Mcp::NotificationSerializer < McpToolkit::Serializer::Base
229
+ class WidgetSerializer < McpToolkit::Serializer::Base
230
230
  attributes :id, :name, :active, :created_at, :updated_at
231
231
  translates :subject, :template_html # Globalize-backed { locale => value }
232
232
 
233
233
  has_one :account, foreign_key: :synced_account_id
234
- has_one :mail_layout
235
- has_many :scheduled_notifications
234
+ has_one :layout
235
+ has_many :scheduled_widgets
236
236
  end
237
237
  ```
238
238
 
@@ -249,6 +249,7 @@ discovery tool, a custom serializer may also expose `declared_attributes` /
249
249
  | Setting | Default | Purpose |
250
250
  |---|---|---|
251
251
  | `server_name` / `server_version` / `server_instructions` | `"mcp-server"` / `"1.0.0"` / `nil` | advertised on `initialize` |
252
+ | `gateway_client_name` / `gateway_client_version` | `server_name` / `server_version` | `clientInfo` a gateway presents to its upstreams (identity split) |
252
253
  | `serializer_base` | `McpToolkit::Serializer::Base` | the default base to subclass |
253
254
  | `auth_role` | `:satellite` | `:satellite` or `:authority` |
254
255
  | `central_app_url` | `nil` | satellite: base URL of the auth authority |
@@ -259,35 +260,315 @@ discovery tool, a custom serializer may also expose `declared_attributes` /
259
260
  | `token_authenticator` | `nil` | authority: `->(plaintext) { token_or_nil }` |
260
261
  | `cache_store` | `MemoryStore` | sessions + introspection cache (set to `Rails.cache`) |
261
262
  | `session_ttl` | `3600` | session sliding TTL (s) |
262
- | `protocol_version` | `nil` (negotiate) | pin an MCP protocol version |
263
- | `parent_controller` | `"ActionController::Base"` | superclass of the engine's `ServerController` (set to `"ApplicationController"` for `helper_method` compat) |
263
+ | `protocol_version` | `nil` (negotiate) | pin an MCP protocol version (satellite/upstream client) |
264
+ | `supported_protocol_versions` | `Protocol::SUPPORTED_VERSIONS` | version set the authority dispatcher negotiates |
265
+ | `tool_provider` | `nil` | authority: the host's api-agnostic tool catalog (see below) |
266
+ | `generic_tool_name_prefix` | `""` | authority: prefix namespacing the four generic Registry-backed tools (e.g. `"foo_"` → `foo_resources` …) |
267
+ | `rate_limiter` / `usage_recorder` / `usage_flusher` | `nil` | authority transport billing hooks (config callables) |
268
+ | `rate_limit_max_requests` | `nil` (off) | authority: per-principal request cap for the built-in `RateLimiter`; `nil` disables rate limiting |
269
+ | `rate_limit_window` | `3600` | authority: fixed rate-limit window (s); ignored while `rate_limit_max_requests` is `nil` |
270
+ | `superuser_resolver` | `nil` | optional `->(principal) -> Boolean` for `Context#superuser?`; `nil` = duck-type `principal.superuser?` |
271
+ | `parent_controller` | `"ActionController::Base"` | superclass of the engine's controllers, read lazily (set to `"ActionController::API"` for the authority, or `"ApplicationController"` for `helper_method` compat) |
264
272
  | `account_meta_key` | `"mcp-toolkit/account-id"` | `_meta` key a superuser uses to pin the account |
265
273
  | `account_id_header` | `"X-MCP-Account-ID"` | header fallback for the account selector |
274
+ | `upstreams` | empty registry | gateway: registered upstream MCP servers (register via `register_upstream`; pass `public_tool_list: false` for a caller-dependent list to opt out of the shared cache) |
275
+ | `upstream_timeout` | `10` | gateway: HTTP timeout (s) for calls to an upstream |
276
+ | `upstream_list_ttl` | `900` | gateway: TTL (s) for an upstream's cached tool list |
277
+ | `logger` | `nil` | optional logger for gateway/session diagnostics (`Rails.logger`) |
266
278
 
267
279
  ## Public API surface
268
280
 
269
281
  - `McpToolkit.configure { |c| ... }`, `McpToolkit.config`, `McpToolkit.registry`,
270
282
  `McpToolkit.reset_config!`
271
283
  - `McpToolkit::Registry#register(name) { ... }` (DSL: `model`, `serializer`,
272
- `scope`, `description`, `filterable`, `required_permissions_scope`) +
284
+ `scope`, `description`, `note`, `filterable`, `filter(name, type:, description:,
285
+ &applier)`, `superusers_only!`, `required_permissions_scope`) +
273
286
  `#default_required_permissions_scope`
274
287
  - `McpToolkit::Serializer::Base` (DSL: `attributes`, `has_one`, `has_many`,
275
288
  `translates`)
276
- - `McpToolkit::Server.build(server_context:, config:, extra_tools:)`
289
+ - `McpToolkit::Server.build(server_context:, config:, extra_tools:)` (satellite,
290
+ SDK-backed)
277
291
  - `McpToolkit::Engine` (mountable; `mount McpToolkit::Engine => "/mcp"`) +
278
- `McpToolkit::ServerController` (its controller; parent via `parent_controller`)
279
- - `McpToolkit::Transport::ControllerMethods` (standalone controller concern;
280
- override `mcp_config` / `mcp_extra_tools`)
281
- - `McpToolkit::Session`
292
+ `McpToolkit::ServerController` (its controller; parent via `parent_controller`,
293
+ built lazily)
294
+ - `McpToolkit::Transport::ControllerMethods` (standalone satellite controller
295
+ concern; override `mcp_config` / `mcp_extra_tools`)
296
+ - **Authority dispatch path** (a first-party server serving its own tools +
297
+ upstreams, no SDK): `McpToolkit::Protocol`,
298
+ `McpToolkit::Dispatcher.new(context:, config:)#handle_request`,
299
+ `McpToolkit::Authority::ControllerMethods` (the transport concern, all
300
+ billing/tenancy steps overridable hooks),
301
+ `McpToolkit::Authority::ServerController` (subclassable base),
302
+ `McpToolkit::Authority::Context` (`account` / `principal` / `bearer_token` /
303
+ `superuser?`), `McpToolkit::Tools::AuthorityBase` (optional tool base),
304
+ `config.tool_provider` (the api-agnostic tool seam),
305
+ `McpToolkit::Authority::RegistryToolProvider.new(config:)` (serves the four
306
+ generic Registry-backed tools `resources` / `resource_schema` / `get` / `list`,
307
+ reusing the executors + schema builder) +
308
+ `McpToolkit::Authority::CompositeToolProvider.new(*providers)` (compose it with
309
+ bespoke tools)
310
+ - `McpToolkit::Session` (opaque `#data` payload, e.g. to bind a session to a token id)
282
311
  - `McpToolkit::Auth::Introspection` / `Authenticator` (satellite),
283
312
  `McpToolkit::Auth::Authority` (authority)
284
313
  - `McpToolkit::Errors::{InvalidParams, Unauthorized, ConfigurationError}`
314
+ - Gateway layer (a central app aggregating/proxying other MCP servers):
315
+ `McpToolkit::Gateway::UpstreamRegistry` (via `config.upstreams` /
316
+ `config.register_upstream`), `McpToolkit::Gateway::{Client, Aggregator, Proxy}`,
317
+ and its errors `McpToolkit::Gateway::{UnknownUpstream, UpstreamCallError}` +
318
+ `McpToolkit::Gateway::Client::Error`
319
+ - `McpToolkit::TokensController` — the authority introspection endpoint drawn by
320
+ the engine at `POST /mcp/tokens/introspect`
285
321
 
286
- ## Scope (what's intentionally NOT here)
322
+ ## Gateway / authority endpoint
287
323
 
288
- The gateway / upstream-aggregation layer (a central app's `Mcp::Upstreams*`) is
289
- **out of scope** it's core-only and ships to no satellite. This toolkit is for
290
- servers that expose tools, not for aggregating other servers.
324
+ Beyond exposing a single server's own tools, the toolkit also ships the generic
325
+ **gateway** layer a central app uses to aggregate *other* MCP servers and proxy
326
+ calls to them, plus the **authority** introspection endpoint satellites call.
327
+ Every app-specific value (the upstream URLs, the account-selector meta key, the
328
+ logger, timeouts) is injected via config — nothing here names a deployment.
329
+
330
+ ### Register upstreams
331
+
332
+ Each upstream has a `key` (the tool-name namespace prefix — its tools surface as
333
+ `<key>__<tool>`) and a `url` (its MCP HTTP endpoint). A blank url is ignored, so
334
+ an ENV lookup can be passed directly:
335
+
336
+ ```ruby
337
+ McpToolkit.configure do |c|
338
+ c.cache_store = Rails.cache # share the upstream tool-list cache across workers
339
+ c.logger = Rails.logger # optional; degrade/recovery diagnostics
340
+ c.register_upstream(key: "notifications", url: ENV["NOTIFICATIONS_SERVER_URL"])
341
+ c.register_upstream(key: "billing", url: ENV["BILLING_SERVER_URL"])
342
+ end
343
+ ```
344
+
345
+ ### Aggregate upstream tool lists
346
+
347
+ `Aggregator#tool_definitions` returns every upstream's tools, namespaced, pulled
348
+ concurrently. Each upstream's list is cached (`config.upstream_list_ttl`, default
349
+ 15 min); only a **non-empty** pull is cached, and a failing upstream is omitted
350
+ (and logged via `config.logger`) rather than breaking the whole list.
351
+
352
+ The cache is keyed by upstream only, so it rests on a registration **contract**:
353
+ an upstream's `tools/list` must be **caller-independent** (the same public tools
354
+ for every valid token; scope enforced only at call time). An upstream that
355
+ filters its list by the caller's privilege (e.g. hides superuser-only tools) must
356
+ register `public_tool_list: false` — it is then pulled live per request and never
357
+ cached, so a privileged caller's list can't leak to an unprivileged one.
358
+
359
+ ```ruby
360
+ c.register_upstream(key: "gateway", url: ENV["GATEWAY_SERVER_URL"], public_tool_list: false)
361
+ ```
362
+
363
+ ```ruby
364
+ definitions = McpToolkit::Gateway::Aggregator.new.tool_definitions(bearer_token: token)
365
+ # => [{ "name" => "notifications__list_items", "description" => ..., "inputSchema" => ... }, ...]
366
+
367
+ McpToolkit::Gateway::Aggregator.new.flush! # bust every upstream's cache
368
+ McpToolkit::Gateway::Aggregator.new.flush!("notifications") # or just one
369
+ ```
370
+
371
+ ### Proxy a namespaced call
372
+
373
+ Split a namespaced tool name via the registry, then proxy it. The caller passes
374
+ the already-resolved account id (a scalar); it is forwarded as
375
+ `_meta[config.account_meta_key]`.
376
+
377
+ ```ruby
378
+ key, bare = McpToolkit.config.upstreams.split_tool_name("notifications__list_items")
379
+ proxy = McpToolkit::Gateway::Proxy.new(
380
+ app_key: key, tool_name: bare, account_id: current_account_id, bearer_token: token
381
+ )
382
+ result = proxy.call({ "since" => "2026-01-01" }) # the upstream's `result` hash, verbatim
383
+ ```
384
+
385
+ The proxy is transport-agnostic: an unregistered key raises
386
+ `McpToolkit::Gateway::UnknownUpstream`, and an upstream call failure raises
387
+ `McpToolkit::Gateway::UpstreamCallError` (carrying the upstream's `jsonrpc_error`
388
+ / `http_status`). Your dispatcher maps those to whatever error shape its transport
389
+ speaks — the gem never welds the gateway to a protocol-error class.
390
+
391
+ ### Authority introspection endpoint (built in)
392
+
393
+ Mounting `McpToolkit::Engine` also draws `POST /mcp/tokens/introspect`, backed by
394
+ the gem-provided `McpToolkit::TokensController`. A central app configured with a
395
+ `token_authenticator` answers introspection with **no controller of its own** —
396
+ the Quickstart 2 hand-rolled controller becomes optional:
397
+
398
+ ```ruby
399
+ # config/routes.rb
400
+ mount McpToolkit::Engine => "/mcp" # POST /mcp/tokens/introspect now works
401
+ ```
402
+
403
+ Drawing it is safe even on an app that is not an authority: with no
404
+ `token_authenticator`, it simply answers `{ "valid": false }`.
405
+
406
+ ## Authority + gateway server (own tools + upstreams, no SDK)
407
+
408
+ Beyond the SDK-backed satellite path, the toolkit also ships a **hand-rolled
409
+ dispatch path** for a first-party server that authenticates tokens LOCALLY and
410
+ serves its OWN tools — and, as a gateway, aggregates + proxies upstreams — with
411
+ the official `mcp` SDK out of the request path. The gem carries the two dispatch
412
+ front-ends side by side: `McpToolkit::Server.build` (satellite) and
413
+ `McpToolkit::Dispatcher` (authority). The wire behavior of the authority path —
414
+ top-level JSON-RPC tool-error codes, `initialize` capabilities, 3-version
415
+ negotiation, verbatim upstream error relay, the custom `list_changed` cache-bust —
416
+ is fixed, so a monetized endpoint keeps its byte contract.
417
+
418
+ ### 1. Expose your tools through a provider (the api-agnostic seam)
419
+
420
+ The gem never sees your API layer. It serves your tools only through a duck-typed
421
+ `tool_provider` you register:
422
+
423
+ ```ruby
424
+ # provider.tool_definitions(context) -> [{ name:, description:, inputSchema: }]
425
+ # provider.find(name) -> a tool object, or nil
426
+ #
427
+ # a tool object responds to:
428
+ # #required_permissions_scope -> String | nil (the gem's per-tool scope gate)
429
+ # #call(context:, **arguments) -> Hash | String (wrapped into { content: [...] })
430
+ McpToolkit.configure do |c|
431
+ c.tool_provider = MyToolProvider.new # your glue over your own tool classes
432
+ end
433
+ ```
434
+
435
+ A tool MAY subclass the bundled base (or be any object satisfying the contract):
436
+
437
+ ```ruby
438
+ class ListWidgets < McpToolkit::Tools::AuthorityBase
439
+ tool_name "list_widgets"
440
+ description "List widgets for the active account."
441
+ required_permissions_scope "widgets__read" # gem gates this centrally
442
+ input_schema { { type: "object", properties: { limit: { type: "integer" } } } }
443
+
444
+ # `account` / `principal` / `bearer_token` / `superuser?` come from the context.
445
+ def call(limit: 25)
446
+ Widget.for(account).limit(limit).map(&:as_json) # your domain, behind #call
447
+ end
448
+ end
449
+ ```
450
+
451
+ `context` is an `McpToolkit::Authority::Context` (`account`, `principal`,
452
+ `bearer_token`, `superuser?`). It is re-created for EVERY JSON-RPC call — including
453
+ each element of a batch — so a mixed-account batch resolves the right account per
454
+ call.
455
+
456
+ #### Or serve the generic Registry-backed tools
457
+
458
+ If your tools are just account-scoped, read-only views over models, you don't need
459
+ to hand-write them. Register each as a resource (exactly as on the satellite side)
460
+ and let the bundled provider serve the same four generic tools — `resources`,
461
+ `resource_schema`, `get`, `list` — over the authority dispatcher:
462
+
463
+ ```ruby
464
+ McpToolkit.configure do |c|
465
+ c.registry.register(:widgets) do
466
+ model Widget
467
+ serializer WidgetSerializer # any class satisfying the serializer contract
468
+ description "Widgets for the active account."
469
+ filterable status: :status, owner_id: :owner_id
470
+ # A resource-specific ("custom") filter: an arbitrary block, keyed by a
471
+ # top-level request param, that the generic equality allowlist can't express.
472
+ filter :for_project, type: :integer, description: "Only widgets in this project" do |relation, id|
473
+ relation.joins(:board).where(boards: { project_id: id })
474
+ end
475
+ superusers_only! # optional: refuse/hide for non-superuser tokens
476
+ note "Read-only projection; do not interpret status codes without domain context."
477
+ scope { |account| Widget.where(account_id: account.id) }
478
+ end
479
+
480
+ # The generic tools, served over config.registry:
481
+ c.tool_provider = McpToolkit::Authority::RegistryToolProvider.new(config: c)
482
+ end
483
+ ```
484
+
485
+ Each generic tool resolves the `resource` argument against the registry, refuses a
486
+ `superusers_only!` resource for a non-superuser (and hides it from `resources`),
487
+ enforces the resource's `required_permissions_scope`, and requires a resolved
488
+ account for `get` / `list`. `resource_schema` advertises each attribute's filter
489
+ `operators` and the resource `note`.
490
+
491
+ By default the four tools advertise their bare names (`resources`,
492
+ `resource_schema`, `get`, `list`). To **namespace** them — e.g. to keep a stable,
493
+ host-specific name for existing clients, or to run several MCP surfaces without
494
+ name collisions — set a prefix:
495
+
496
+ ```ruby
497
+ c.generic_tool_name_prefix = "foo_" # advertised + resolved as foo_resources,
498
+ # foo_resource_schema, foo_get, foo_list
499
+ ```
500
+
501
+ The prefix applies only to these four generic tools; a composed bespoke provider's
502
+ own tool names are unaffected.
503
+
504
+ To serve the generic tools **and** your own bespoke tools behind one provider,
505
+ compose them:
506
+
507
+ ```ruby
508
+ c.tool_provider = McpToolkit::Authority::CompositeToolProvider.new(
509
+ McpToolkit::Authority::RegistryToolProvider.new(config: c),
510
+ MyBespokeToolProvider.new # e.g. an audit/versions tool
511
+ )
512
+ ```
513
+
514
+ ### 2. Serve it through the authority transport
515
+
516
+ A **pure host** mounts the engine's authority base and drives everything from
517
+ config callables. A host whose rate-limit / usage / account logic touches its own
518
+ models **subclasses** the base and overrides the hook methods:
519
+
520
+ ```ruby
521
+ class ServerController < McpToolkit::Authority::ServerController
522
+ # Local token auth, session binding, and account resolution have working
523
+ # defaults (duck-typed on your token via config.token_authenticator). Override
524
+ # only what touches your models:
525
+ def mcp_rate_limit!
526
+ # ...your limiter; render + halt when over the limit...
527
+ end
528
+
529
+ def mcp_track_usage(request_data, account)
530
+ # ...accumulate one usage row for this call (fires per batch element)...
531
+ end
532
+
533
+ def mcp_flush_usage = # ...bulk-insert the accumulated rows...
534
+ def mcp_session_data = { token_id: mcp_principal.id } # revoked token kills the session
535
+ end
536
+ ```
537
+
538
+ Every billing/tenancy step is an overridable hook: `mcp_authenticate!`,
539
+ `mcp_rate_limit!`, `mcp_track_usage`, `mcp_flush_usage`, `mcp_resolve_account`,
540
+ `mcp_session_data`, `mcp_dispatch`, `mcp_health_payload`, `mcp_config`. The
541
+ per-request loop (`resolve account → track usage → dispatch`) is preserved across
542
+ batches, so usage metering survives a mixed-account batch.
543
+
544
+ **Rate limiting is built in.** Set `config.rate_limit_max_requests` (and,
545
+ optionally, `config.rate_limit_window`, default 1 hour) and the default
546
+ `mcp_rate_limit!` throttles each principal via `McpToolkit::RateLimiter` against
547
+ `config.cache_store` — no subclass needed. It sets the `X-RateLimit-*` headers on
548
+ every capped response and, over the limit, renders a JSON-RPC `-32029` error at
549
+ `429` with `Retry-After`. A host that keeps its cap in a constant/model overrides
550
+ the small `mcp_rate_limit_max_requests` hook (default `config.rate_limit_max_requests`);
551
+ `mcp_rate_limit_key` (default `mcp_principal.id`) buckets the counter. Leaving the
552
+ cap `nil` disables throttling entirely; `config.rate_limiter` stays as an escape
553
+ hatch that replaces the built-in wholesale.
554
+
555
+ **Superuser is an optional, first-class concept.** Set
556
+ `config.superuser_resolver = ->(principal) { ... }` and `Context#superuser?` uses
557
+ it to gate `superusers_only!` resources; with no resolver it duck-types
558
+ `principal.superuser?`, and with neither, no caller is ever a superuser.
559
+
560
+ Point your `POST /mcp` route at the subclass (or mount the engine for a pure host);
561
+ keep `POST /mcp/tokens/introspect` on the gem's `TokensController`.
562
+
563
+ ### Lazy `parent_controller`
564
+
565
+ The gem's controllers subclass `config.parent_controller`. That parent is read
566
+ only at **build** time — the controllers are built by
567
+ `McpToolkit.build_engine_controllers!`, triggered lazily on first reference and
568
+ reset on each reload by the engine's `to_prepare` — so it is always resolved AFTER
569
+ your app's initializers/to_prepare. Your whole MCP initializer can therefore live
570
+ in `to_prepare`. Set `c.parent_controller = "ActionController::API"` for the
571
+ authority.
291
572
 
292
573
  ## Development
293
574
 
data/config/routes.rb CHANGED
@@ -6,13 +6,28 @@
6
6
  #
7
7
  # mount McpToolkit::Engine => "/mcp"
8
8
  #
9
- # POST /mcp -> create (JSON-RPC requests/responses)
10
- # GET /mcp -> stream (405; no server-initiated SSE)
11
- # DELETE /mcp -> destroy (terminate the session)
12
- # GET /mcp/health -> health (unauthenticated probe)
9
+ # POST /mcp -> create (JSON-RPC requests/responses)
10
+ # GET /mcp -> stream (405; no server-initiated SSE)
11
+ # DELETE /mcp -> destroy (terminate the session)
12
+ # GET /mcp/health -> health (unauthenticated probe)
13
+ # POST /mcp/tokens/introspect -> introspect (authority token introspection;
14
+ # drawn ONLY when this app is an
15
+ # authority — see below)
13
16
  McpToolkit::Engine.routes.draw do
14
17
  post "/", to: "server#create"
15
18
  get "/", to: "server#stream"
16
19
  delete "/", to: "server#destroy"
17
20
  get "health", to: "server#health"
21
+
22
+ # Introspection is the PROVIDER side of the token protocol. A satellite (the
23
+ # default role) introspects its own tokens AGAINST a central authority and must
24
+ # never itself answer introspection, so the endpoint is drawn only when this app
25
+ # is configured as an authority — a satellite that mounts the full engine gets no
26
+ # such route at all (not merely a failing one). Rails evaluates this file through
27
+ # the routes_reloader, which runs AFTER the host's initializers/to_prepare, so
28
+ # `auth_role` is already set; and the config default is `:satellite`, so an
29
+ # unconfigured host safely omits it. An app that is both keeps it (`authority?`
30
+ # is true whenever `auth_role == :authority`). The controller also fails safe
31
+ # (no `token_authenticator` => `{ valid: false }`), so this is defence in depth.
32
+ post "tokens/introspect", to: "tokens#introspect" if McpToolkit.config.authority?
18
33
  end
@@ -5,7 +5,7 @@
5
5
  # introspection request satellites send.
6
6
  #
7
7
  # Both are thin and config-driven: the actual token lookup is the app's
8
- # `config.token_authenticator` callable (its `McpToken.authenticate`
8
+ # `config.token_authenticator` callable (its `AccessToken.authenticate`
9
9
  # equivalent), and the introspection payload is derived from the duck-typed
10
10
  # token object that callable returns.
11
11
  #
@@ -23,7 +23,7 @@
23
23
  # The sole authorization source on the satellite side.
24
24
  #
25
25
  # Optionally `#touch_last_used!` (called after a successful authenticate if
26
- # present). A typical app token model (e.g. `McpToken`) satisfies this.
26
+ # present). A typical app token model (e.g. `AccessToken`) satisfies this.
27
27
  module McpToolkit::Auth::Authority
28
28
  # Authenticate a plaintext bearer locally. Returns the token object or nil.
29
29
  # Calls `touch_last_used!` on the token if it responds to it (throttled
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Composes several `tool_provider`s into one, so a host can serve the generic
4
+ # Registry-backed tools (McpToolkit::Authority::RegistryToolProvider) ALONGSIDE
5
+ # its own bespoke tools (e.g. a paper-trail/versions tool that doesn't fit the
6
+ # generic resource model) behind a single `config.tool_provider`.
7
+ #
8
+ # It satisfies the same duck-typed provider contract the dispatcher calls:
9
+ # tool_definitions(context) -> the concatenation of every provider's definitions,
10
+ # in registration order
11
+ # find(name) -> the first provider (in order) that resolves the
12
+ # name, else nil
13
+ #
14
+ # Ordering is significant only if two providers advertise the same tool name; the
15
+ # first registered wins. A host controls precedence by argument order.
16
+ class McpToolkit::Authority::CompositeToolProvider
17
+ def initialize(*providers)
18
+ @providers = providers
19
+ end
20
+
21
+ def tool_definitions(context)
22
+ @providers.flat_map { |provider| provider.tool_definitions(context) }
23
+ end
24
+
25
+ def find(name)
26
+ @providers.each do |provider|
27
+ tool = provider.find(name)
28
+ return tool if tool
29
+ end
30
+ nil
31
+ end
32
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The per-JSON-RPC-request context the authority transport threads into the
4
+ # dispatcher and, through it, into each host tool. Re-created for EVERY element of
5
+ # a batch so each call carries its own resolved account (the property usage
6
+ # metering relies on).
7
+ #
8
+ # It carries three host-supplied values and derives one:
9
+ # * `account` — the tenant resolved for THIS call (nil when none applies).
10
+ # The object need only respond to `#id` (forwarded upstream
11
+ # as the account selector).
12
+ # * `principal` — the authenticated caller (the token object). Duck-typed;
13
+ # the gem calls `#authorized_for_scope?(scope)` (tool scope
14
+ # gate) and, optionally, `#superuser?` (see `#superuser?`).
15
+ # The transport's account resolution additionally uses
16
+ # `#default_account` / `#authorize_account(id)`.
17
+ # * `bearer_token` — the raw bearer, forwarded to upstream MCP servers so they
18
+ # introspect the same token and resolve the same account.
19
+ # * `superuser?` — derived. When `config.superuser_resolver` is set, it is the
20
+ # truth of `resolver.call(principal)`; otherwise the context
21
+ # duck-types `principal.superuser?` (false when the principal
22
+ # doesn't respond to it). Lets a host tool base
23
+ # (McpToolkit::Tools::AuthorityBase) gate `superusers_only!`
24
+ # resources without the gem naming any app concept. Superuser
25
+ # is fully OPTIONAL — with no resolver and a principal that
26
+ # isn't superuser-aware, it is always false.
27
+ class McpToolkit::Authority::Context
28
+ attr_reader :account, :principal, :bearer_token
29
+
30
+ def initialize(account:, principal:, bearer_token: nil)
31
+ @account = account
32
+ @principal = principal
33
+ @bearer_token = bearer_token
34
+ end
35
+
36
+ # Superuser-ness of the caller. A configured `superuser_resolver` is the
37
+ # first-class hook; absent one, we fall back to duck-typing `principal.superuser?`
38
+ # so a host that just defines that method on its token still works.
39
+ def superuser?
40
+ resolver = McpToolkit.config.superuser_resolver
41
+ return !!resolver.call(principal) if resolver
42
+
43
+ principal.respond_to?(:superuser?) ? !!principal.superuser? : false
44
+ end
45
+ end