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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +201 -2
- data/README.md +315 -34
- data/config/routes.rb +19 -4
- data/lib/mcp_toolkit/auth/authority.rb +2 -2
- data/lib/mcp_toolkit/authority/composite_tool_provider.rb +32 -0
- data/lib/mcp_toolkit/authority/context.rb +45 -0
- data/lib/mcp_toolkit/authority/controller_methods.rb +408 -0
- data/lib/mcp_toolkit/authority/registry_tool_provider.rb +70 -0
- data/lib/mcp_toolkit/authority/token.rb +124 -0
- data/lib/mcp_toolkit/authority/tools/base.rb +122 -0
- data/lib/mcp_toolkit/authority/tools/get.rb +58 -0
- data/lib/mcp_toolkit/authority/tools/list.rb +84 -0
- data/lib/mcp_toolkit/authority/tools/resource_schema.rb +44 -0
- data/lib/mcp_toolkit/authority/tools/resources.rb +33 -0
- data/lib/mcp_toolkit/authority.rb +24 -0
- data/lib/mcp_toolkit/configuration.rb +205 -2
- data/lib/mcp_toolkit/dispatcher.rb +234 -0
- data/lib/mcp_toolkit/engine.rb +22 -7
- data/lib/mcp_toolkit/engine_controllers.rb +116 -0
- data/lib/mcp_toolkit/gateway/aggregator.rb +122 -0
- data/lib/mcp_toolkit/gateway/client.rb +305 -0
- data/lib/mcp_toolkit/gateway/proxy.rb +70 -0
- data/lib/mcp_toolkit/gateway/unknown_upstream.rb +8 -0
- data/lib/mcp_toolkit/gateway/upstream_call_error.rb +23 -0
- data/lib/mcp_toolkit/gateway/upstream_registry.rb +79 -0
- data/lib/mcp_toolkit/list_executor.rb +17 -0
- data/lib/mcp_toolkit/protocol.rb +106 -0
- data/lib/mcp_toolkit/rate_limiter.rb +73 -0
- data/lib/mcp_toolkit/registry.rb +30 -2
- data/lib/mcp_toolkit/resource.rb +125 -7
- data/lib/mcp_toolkit/resource_schema.rb +14 -1
- data/lib/mcp_toolkit/serializer/base.rb +2 -2
- data/lib/mcp_toolkit/session.rb +17 -9
- data/lib/mcp_toolkit/tools/authority_base.rb +127 -0
- data/lib/mcp_toolkit/transport/controller_methods.rb +2 -2
- data/lib/mcp_toolkit/usage_metering/recorder.rb +95 -0
- data/lib/mcp_toolkit/version.rb +1 -1
- data/lib/mcp_toolkit.rb +16 -3
- metadata +38 -2
- 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 = "
|
|
65
|
-
c.server_instructions = "Read-only access to this account's
|
|
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 "
|
|
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(:
|
|
98
|
-
model
|
|
99
|
-
serializer
|
|
100
|
-
description "
|
|
101
|
-
scope(&:
|
|
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(:
|
|
105
|
-
model
|
|
106
|
-
serializer
|
|
107
|
-
description "Scheduled
|
|
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 "
|
|
112
|
-
scope { |account|
|
|
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 `
|
|
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) {
|
|
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. `
|
|
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
|
|
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: "
|
|
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
|
|
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 :
|
|
235
|
-
has_many :
|
|
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
|
-
| `
|
|
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`, `
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
##
|
|
322
|
+
## Gateway / authority endpoint
|
|
287
323
|
|
|
288
|
-
|
|
289
|
-
**
|
|
290
|
-
|
|
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
|
|
10
|
-
# GET /mcp
|
|
11
|
-
# DELETE /mcp
|
|
12
|
-
# GET /mcp/health
|
|
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 `
|
|
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. `
|
|
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
|