mcp_toolkit 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +432 -2
- data/README.md +315 -34
- data/config/routes.rb +20 -5
- 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 +423 -0
- data/lib/mcp_toolkit/authority/registry_tool_provider.rb +69 -0
- data/lib/mcp_toolkit/authority/single_tool_provider.rb +25 -0
- data/lib/mcp_toolkit/authority/token.rb +124 -0
- data/lib/mcp_toolkit/authority/tools/base.rb +150 -0
- data/lib/mcp_toolkit/authority/tools/get.rb +59 -0
- data/lib/mcp_toolkit/authority/tools/list.rb +132 -0
- data/lib/mcp_toolkit/authority/tools/resource_schema.rb +52 -0
- data/lib/mcp_toolkit/authority/tools/resources.rb +55 -0
- data/lib/mcp_toolkit/authority.rb +24 -0
- data/lib/mcp_toolkit/configuration.rb +362 -5
- data/lib/mcp_toolkit/dispatcher.rb +248 -0
- data/lib/mcp_toolkit/engine.rb +26 -8
- data/lib/mcp_toolkit/engine_controllers.rb +124 -0
- data/lib/mcp_toolkit/filtering.rb +168 -23
- data/lib/mcp_toolkit/gateway/aggregator.rb +142 -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 +65 -4
- 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 +173 -6
- data/lib/mcp_toolkit/resource_schema.rb +135 -13
- data/lib/mcp_toolkit/serializer/association_descriptor.rb +11 -0
- data/lib/mcp_toolkit/serializer/base.rb +2 -2
- data/lib/mcp_toolkit/serializer/target_ref.rb +7 -0
- data/lib/mcp_toolkit/session.rb +18 -10
- data/lib/mcp_toolkit/tool_reference_rewriter.rb +33 -0
- data/lib/mcp_toolkit/tools/authority_base.rb +148 -0
- data/lib/mcp_toolkit/tools/base.rb +23 -3
- data/lib/mcp_toolkit/tools/get.rb +1 -1
- data/lib/mcp_toolkit/tools/list.rb +27 -9
- data/lib/mcp_toolkit/tools/resource_schema.rb +12 -3
- data/lib/mcp_toolkit/tools/resources.rb +30 -7
- data/lib/mcp_toolkit/transport/controller_methods.rb +2 -2
- data/lib/mcp_toolkit/usage_metering/recorder.rb +121 -0
- data/lib/mcp_toolkit/version.rb +1 -1
- data/lib/mcp_toolkit.rb +16 -3
- metadata +42 -2
- data/app/controllers/mcp_toolkit/server_controller.rb +0 -19
|
@@ -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
|
-
# `
|
|
102
|
+
# `AccessToken.authenticate(plaintext)` equivalent. Required for the :authority
|
|
85
103
|
# role; unused by a pure satellite.
|
|
86
104
|
#
|
|
87
|
-
# c.token_authenticator = ->(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
|
|
@@ -118,12 +169,108 @@ class McpToolkit::Configuration
|
|
|
118
169
|
# @return [#sanitize_sql_like]
|
|
119
170
|
attr_accessor :sql_sanitizer
|
|
120
171
|
|
|
172
|
+
# NOTE: (all data-path settings below): the list/get executors and the schema
|
|
173
|
+
# builder read the PROCESS-GLOBAL `McpToolkit.config` — a per-instance config
|
|
174
|
+
# bound to a provider affects tool prose only. Configure these globally.
|
|
175
|
+
#
|
|
176
|
+
# How a BARE (non-operator) filter value is interpreted by the list executor:
|
|
177
|
+
#
|
|
178
|
+
# :tokenized (default) — a comma-separated string becomes an IN set, the
|
|
179
|
+
# "null" token (and a JSON null) matches NULL rows, an Array of non-null
|
|
180
|
+
# scalars is an IN set (nil/Hash/nested-Array elements rejected), and an
|
|
181
|
+
# empty string means "no filter".
|
|
182
|
+
# :literal — the value is handed to the WHERE clause verbatim (an op-less
|
|
183
|
+
# Hash is still rejected). This preserves an EXISTING API contract for a
|
|
184
|
+
# host whose pre-gem endpoint matched bare values literally: "a,b" is one
|
|
185
|
+
# literal string, "null" is the literal string, "" matches empty-string
|
|
186
|
+
# rows, an Array (including nil elements) gets the adapter's native IN /
|
|
187
|
+
# OR-IS-NULL handling.
|
|
188
|
+
#
|
|
189
|
+
# Operator conditions ({ op:, value: }) behave identically in both modes.
|
|
190
|
+
#
|
|
191
|
+
# @return [Symbol] :tokenized or :literal
|
|
192
|
+
attr_accessor :bare_filter_value_semantics
|
|
193
|
+
|
|
194
|
+
# Default ordering for a resource whose primary key is NON-numeric (numeric
|
|
195
|
+
# PKs always order by :id):
|
|
196
|
+
#
|
|
197
|
+
# :created_at (default) — ORDER BY created_at, <pk> (chronological pages
|
|
198
|
+
# with a total-order tiebreaker).
|
|
199
|
+
# :primary_key — ORDER BY <pk> only. Preserves an EXISTING API contract for
|
|
200
|
+
# a host whose pre-gem endpoint ordered every list by id.
|
|
201
|
+
#
|
|
202
|
+
# @return [Symbol] :created_at or :primary_key
|
|
203
|
+
attr_accessor :non_numeric_pk_order
|
|
204
|
+
|
|
205
|
+
# Per-column-type overrides for the operator sets advertised by
|
|
206
|
+
# `resource_schema` and enforced by the list executor, merged over
|
|
207
|
+
# McpToolkit::Filtering::OPERATORS_BY_TYPE. Lets a host preserve an EXISTING
|
|
208
|
+
# operator contract exactly — both the schema bytes and which conditions are
|
|
209
|
+
# accepted — e.g. `{ text: %w[eq in], date: %w[eq in] }` for a pre-gem
|
|
210
|
+
# endpoint that never offered comparisons on those types. Empty by default
|
|
211
|
+
# (the gem's own sets apply).
|
|
212
|
+
#
|
|
213
|
+
# @return [Hash{Symbol => Array<String>}]
|
|
214
|
+
attr_reader :filter_operator_overrides
|
|
215
|
+
|
|
216
|
+
# Assigns per-type operator overrides, rejecting any operator the gem cannot
|
|
217
|
+
# safely dispatch. Only McpToolkit::Filtering::AREL_PREDICATIONS map onto an
|
|
218
|
+
# Arel predication that binds/quotes its value; anything else (e.g. "extract")
|
|
219
|
+
# would be public_send to an Arel attribute with the request value passed
|
|
220
|
+
# through VERBATIM — an SQL-injection surface. Fail fast at config time so a
|
|
221
|
+
# typo can never open that door at request time.
|
|
222
|
+
def filter_operator_overrides=(overrides)
|
|
223
|
+
overrides ||= {}
|
|
224
|
+
unless overrides.is_a?(Hash)
|
|
225
|
+
raise ArgumentError,
|
|
226
|
+
"filter_operator_overrides must be a Hash of { column_type => [operator, ...] }, " \
|
|
227
|
+
"got #{overrides.class}"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
overrides.each do |type, operators|
|
|
231
|
+
unsupported = Array(operators).map(&:to_s) - McpToolkit::Filtering::AREL_PREDICATIONS
|
|
232
|
+
next if unsupported.empty?
|
|
233
|
+
|
|
234
|
+
raise ArgumentError,
|
|
235
|
+
"filter_operator_overrides[#{type.inspect}] has unsupported operator(s): " \
|
|
236
|
+
"#{unsupported.join(", ")}. Allowed: #{McpToolkit::Filtering::AREL_PREDICATIONS.join(", ")}."
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
@filter_operator_overrides = overrides
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Caps how many values an IN-set filter may resolve to, and how many operator
|
|
243
|
+
# conditions may be ANDed on a single attribute, so a valid token can't emit
|
|
244
|
+
# an unbounded IN clause / AND-chain (oversized SQL + Arel AST and expensive
|
|
245
|
+
# planning; rate limiting is opt-in via rate_limit_max_requests). Applies to
|
|
246
|
+
# the default :tokenized bare-value semantics and to { op:, value: }
|
|
247
|
+
# conditions. nil disables the cap.
|
|
248
|
+
#
|
|
249
|
+
# @return [Integer, nil]
|
|
250
|
+
attr_accessor :max_filter_values
|
|
251
|
+
|
|
252
|
+
# Caps how many JSON-RPC calls a single POST batch may carry on the authority
|
|
253
|
+
# transport. Rate limiting is per-HTTP-request, so an uncapped batch would let
|
|
254
|
+
# one request fan out unbounded work (N tool executions / N upstream calls)
|
|
255
|
+
# under a single rate-limit tick. nil disables the cap.
|
|
256
|
+
#
|
|
257
|
+
# @return [Integer, nil]
|
|
258
|
+
attr_accessor :max_batch_size
|
|
259
|
+
|
|
121
260
|
# --- protocol / transport --------------------------------------------------
|
|
122
261
|
|
|
123
262
|
# @return [String, nil] protocol version to pin on the underlying MCP::Server.
|
|
124
263
|
# nil lets the gem negotiate (recommended). Set only to force an older spec.
|
|
125
264
|
attr_accessor :protocol_version
|
|
126
265
|
|
|
266
|
+
# The protocol versions the hand-rolled AUTHORITY dispatcher
|
|
267
|
+
# (McpToolkit::Dispatcher) negotiates, newest first. `initialize` echoes the
|
|
268
|
+
# requested version when it is in this set, else the first (latest). Defaults to
|
|
269
|
+
# McpToolkit::Protocol::SUPPORTED_VERSIONS; override to pin a host's own set.
|
|
270
|
+
#
|
|
271
|
+
# @return [Array<String>]
|
|
272
|
+
attr_accessor :supported_protocol_versions
|
|
273
|
+
|
|
127
274
|
# The parent class (as a String, resolved via `constantize`) of the
|
|
128
275
|
# gem-provided McpToolkit::ServerController that McpToolkit::Engine mounts.
|
|
129
276
|
# Doorkeeper-style indirection so a satellite mounting the engine can keep
|
|
@@ -151,12 +298,131 @@ class McpToolkit::Configuration
|
|
|
151
298
|
# @return [McpToolkit::Registry]
|
|
152
299
|
attr_accessor :registry
|
|
153
300
|
|
|
301
|
+
# --- gateway / upstreams ---------------------------------------------------
|
|
302
|
+
|
|
303
|
+
# @return [Integer] HTTP open/read timeout (s) for a gateway's calls to an
|
|
304
|
+
# upstream MCP server (McpToolkit::Gateway::Client).
|
|
305
|
+
attr_accessor :upstream_timeout
|
|
306
|
+
# @return [Integer] TTL (s) for an upstream's cached, namespaced tool list in
|
|
307
|
+
# McpToolkit::Gateway::Aggregator.
|
|
308
|
+
attr_accessor :upstream_list_ttl
|
|
309
|
+
|
|
310
|
+
# The registry of upstream MCP servers this gateway aggregates + proxies to.
|
|
311
|
+
# Each config carries its own (like `registry`), so it resets with a fresh
|
|
312
|
+
# config. Register via the `register_upstream` sugar below or directly on this
|
|
313
|
+
# instance. Empty unless the app registers upstreams, so a non-gateway app is
|
|
314
|
+
# unaffected.
|
|
315
|
+
#
|
|
316
|
+
# @return [McpToolkit::Gateway::UpstreamRegistry]
|
|
317
|
+
attr_reader :upstreams
|
|
318
|
+
|
|
319
|
+
# --- authority hooks -------------------------------------------------------
|
|
320
|
+
#
|
|
321
|
+
# Injection points for the AUTHORITY transport (McpToolkit::Authority::
|
|
322
|
+
# ControllerMethods) so a PURE host drives billing/tenancy from config without
|
|
323
|
+
# subclassing. A host whose logic touches its own models overrides the matching
|
|
324
|
+
# hook METHOD on its McpToolkit::Authority::ServerController subclass instead;
|
|
325
|
+
# then these stay nil. All default to nil (a no-op).
|
|
326
|
+
|
|
327
|
+
# OPTIONAL escape hatch that FULLY REPLACES the built-in limiter: a
|
|
328
|
+
# `->(controller:, principal:)` that renders + halts when over the limit (or
|
|
329
|
+
# sets rate-limit headers when under). When set, `mcp_rate_limit!` delegates to
|
|
330
|
+
# it and the built-in (`rate_limit_max_requests`) is skipped. nil (the default)
|
|
331
|
+
# means the built-in runs instead. Most hosts want the built-in; reach for this
|
|
332
|
+
# only when the counting itself must live in app code.
|
|
333
|
+
#
|
|
334
|
+
# @return [#call, nil]
|
|
335
|
+
attr_accessor :rate_limiter
|
|
336
|
+
|
|
337
|
+
# Records ONE usage event for a single JSON-RPC call (called per batch element).
|
|
338
|
+
# `->(request_data:, account:, principal:, controller:)`. MUST never affect the
|
|
339
|
+
# MCP response. nil = no metering.
|
|
340
|
+
#
|
|
341
|
+
# @return [#call, nil]
|
|
342
|
+
attr_accessor :usage_recorder
|
|
343
|
+
|
|
344
|
+
# Persists accumulated usage after the response (an after_action).
|
|
345
|
+
# `->(controller:)`. MUST never affect the MCP response. nil = no flush.
|
|
346
|
+
#
|
|
347
|
+
# @return [#call, nil]
|
|
348
|
+
attr_accessor :usage_flusher
|
|
349
|
+
|
|
350
|
+
# Builds the opaque payload bound to a session on `initialize`. `->(principal:)`
|
|
351
|
+
# returning a Hash (or nil for none). Lets a host bind e.g.
|
|
352
|
+
# `{ token_id: principal.id }` so a revoked token can kill an in-flight session,
|
|
353
|
+
# WITHOUT overriding the controller's `mcp_session_data`. nil (the default) =>
|
|
354
|
+
# an empty session payload.
|
|
355
|
+
#
|
|
356
|
+
# @return [#call, nil]
|
|
357
|
+
attr_accessor :session_data_builder
|
|
358
|
+
|
|
359
|
+
# The host's tool catalog — the api-agnostic seam. Duck-typed; the dispatcher
|
|
360
|
+
# calls:
|
|
361
|
+
#
|
|
362
|
+
# provider.tool_definitions(context) -> [{ name:, description:, inputSchema: }]
|
|
363
|
+
# provider.find(name) -> a tool object, or nil
|
|
364
|
+
#
|
|
365
|
+
# where a tool object responds to `#required_permissions_scope` (String|nil, the
|
|
366
|
+
# gem's scope gate) and `#call(context:, **arguments)` (returns Hash|String,
|
|
367
|
+
# which the gem wraps into `{ content: [{ type: "text", text: }] }`). See
|
|
368
|
+
# McpToolkit::Tools::AuthorityBase for a base that satisfies this.
|
|
369
|
+
#
|
|
370
|
+
# UNSET (the default), the provider is composed automatically: the generic
|
|
371
|
+
# RegistryToolProvider (bound to this config) first, then every entry of
|
|
372
|
+
# `extra_tool_providers` — so a host that only serves the generic tools plus
|
|
373
|
+
# its own bespoke ones needs no provider plumbing at all. Assign explicitly to
|
|
374
|
+
# take full control of the catalog.
|
|
375
|
+
#
|
|
376
|
+
# @return [#tool_definitions, #find]
|
|
377
|
+
attr_writer :tool_provider
|
|
378
|
+
|
|
379
|
+
def tool_provider
|
|
380
|
+
@tool_provider || composed_tool_provider
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Additional tool providers (or bare TOOL classes, auto-wrapped in a
|
|
384
|
+
# SingleToolProvider) composed AFTER the generic Registry-backed tools when
|
|
385
|
+
# `tool_provider` is not explicitly assigned. The registry provider is always
|
|
386
|
+
# first, so a generic tool name resolves to it; extras only answer their own
|
|
387
|
+
# names.
|
|
388
|
+
#
|
|
389
|
+
# config.extra_tool_providers = [MyApp::Tools::AuditLog]
|
|
390
|
+
#
|
|
391
|
+
# @return [Array<#tool_definitions, Class>]
|
|
392
|
+
attr_accessor :extra_tool_providers
|
|
393
|
+
|
|
394
|
+
# --- generic tool naming ---------------------------------------------------
|
|
395
|
+
|
|
396
|
+
# A prefix prepended to the four GENERIC, Registry-backed authority tool names
|
|
397
|
+
# (`resources`, `resource_schema`, `get`, `list`) served by
|
|
398
|
+
# McpToolkit::Authority::RegistryToolProvider. Lets a host NAMESPACE its generic
|
|
399
|
+
# tools — e.g. set `"foo_"` and they advertise (and resolve) as `foo_resources`,
|
|
400
|
+
# `foo_resource_schema`, `foo_get`, `foo_list` — so distinct MCP surfaces don't
|
|
401
|
+
# collide and existing clients keep a stable, host-chosen name. Empty by default,
|
|
402
|
+
# so the tools keep their bare base names. The prefix value is the host's; the gem
|
|
403
|
+
# names no app concept.
|
|
404
|
+
#
|
|
405
|
+
# @return [String]
|
|
406
|
+
attr_accessor :generic_tool_name_prefix
|
|
407
|
+
|
|
408
|
+
# --- diagnostics -----------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
# Optional logger for gateway/session diagnostics. All call sites guard with
|
|
411
|
+
# `logger&.warn` / `logger&.error`, so nil (the default) silences them. A Rails
|
|
412
|
+
# host typically sets this to `Rails.logger`.
|
|
413
|
+
#
|
|
414
|
+
# @return [#warn, #error, nil]
|
|
415
|
+
attr_accessor :logger
|
|
416
|
+
|
|
154
417
|
# Vendor-neutral defaults; apps override the auth wiring + identity as needed.
|
|
155
418
|
def initialize
|
|
156
419
|
@server_name = "mcp-server"
|
|
157
420
|
@server_version = "1.0.0"
|
|
158
421
|
@server_instructions = nil
|
|
159
422
|
|
|
423
|
+
@gateway_client_name = nil
|
|
424
|
+
@gateway_client_version = nil
|
|
425
|
+
|
|
160
426
|
@serializer_base = nil # set lazily in #serializer_base to avoid load-order issues
|
|
161
427
|
|
|
162
428
|
@auth_role = :satellite
|
|
@@ -169,16 +435,107 @@ class McpToolkit::Configuration
|
|
|
169
435
|
@token_authenticator = nil
|
|
170
436
|
|
|
171
437
|
@cache_store = ActiveSupport::Cache::MemoryStore.new
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
@sql_sanitizer = McpToolkit::SqlSanitizer.new
|
|
438
|
+
initialize_data_path_defaults
|
|
175
439
|
|
|
176
440
|
@protocol_version = nil
|
|
441
|
+
@supported_protocol_versions = McpToolkit::Protocol::SUPPORTED_VERSIONS
|
|
177
442
|
@parent_controller = "ActionController::Base"
|
|
178
443
|
@account_meta_key = "mcp-toolkit/account-id"
|
|
179
444
|
@account_id_header = "X-MCP-Account-ID"
|
|
180
445
|
|
|
446
|
+
initialize_authority_hook_defaults
|
|
447
|
+
@generic_tool_name_prefix = ""
|
|
448
|
+
|
|
449
|
+
@upstream_timeout = 10
|
|
450
|
+
@upstream_list_ttl = 900 # 15 minutes
|
|
451
|
+
@logger = nil
|
|
452
|
+
|
|
181
453
|
@registry = McpToolkit::Registry.new
|
|
454
|
+
@upstreams = McpToolkit::Gateway::UpstreamRegistry.new
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Session-TTL and list-executor defaults: the :tokenized / :created_at
|
|
458
|
+
# data-path semantics (a host preserving a pre-gem contract overrides these —
|
|
459
|
+
# see each accessor's docs).
|
|
460
|
+
def initialize_data_path_defaults
|
|
461
|
+
@session_ttl = 3600 # 1 hour
|
|
462
|
+
|
|
463
|
+
@sql_sanitizer = McpToolkit::SqlSanitizer.new
|
|
464
|
+
@bare_filter_value_semantics = :tokenized
|
|
465
|
+
@non_numeric_pk_order = :created_at
|
|
466
|
+
@filter_operator_overrides = {}
|
|
467
|
+
@max_filter_values = 500
|
|
468
|
+
@max_batch_size = 50
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# The default authority tool catalog when no explicit `tool_provider` is
|
|
472
|
+
# assigned: the generic Registry-backed tools first (so a generic name always
|
|
473
|
+
# resolves to them; included only when the host registered resources — a pure
|
|
474
|
+
# gateway keeps contributing nothing), then each extra provider — a bare tool
|
|
475
|
+
# CLASS is wrapped in a SingleToolProvider. Built per read (cheap, stateless
|
|
476
|
+
# providers), so a reload that re-assigns `extra_tool_providers` or registers
|
|
477
|
+
# resources takes effect immediately.
|
|
478
|
+
def composed_tool_provider
|
|
479
|
+
providers = []
|
|
480
|
+
providers << McpToolkit::Authority::RegistryToolProvider.new(config: self) if registry.resources.any?
|
|
481
|
+
Array(extra_tool_providers).each do |provider|
|
|
482
|
+
providers << (provider.respond_to?(:tool_definitions) ? provider : McpToolkit::Authority::SingleToolProvider.new(provider))
|
|
483
|
+
end
|
|
484
|
+
return nil if providers.empty?
|
|
485
|
+
return providers.first if providers.one?
|
|
486
|
+
|
|
487
|
+
McpToolkit::Authority::CompositeToolProvider.new(*providers)
|
|
488
|
+
end
|
|
489
|
+
private :composed_tool_provider
|
|
490
|
+
|
|
491
|
+
# The authority transport's injection points all default to nil (a no-op): a
|
|
492
|
+
# pure satellite/gateway never touches them. `rate_limit_window` is the sole
|
|
493
|
+
# non-nil default (the window size only matters once a cap opts in).
|
|
494
|
+
def initialize_authority_hook_defaults
|
|
495
|
+
@rate_limiter = nil
|
|
496
|
+
@usage_recorder = nil
|
|
497
|
+
@usage_flusher = nil
|
|
498
|
+
@session_data_builder = nil
|
|
499
|
+
@tool_provider = nil
|
|
500
|
+
@extra_tool_providers = []
|
|
501
|
+
@rate_limit_max_requests = nil # nil = rate limiting disabled
|
|
502
|
+
@rate_limit_window = 3600 # 1 hour
|
|
503
|
+
@superuser_resolver = nil # nil = duck-type principal.superuser?
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# Config sugar: register a gateway upstream. Delegates to `upstreams.register`,
|
|
507
|
+
# so a blank url is ignored (an unconfigured upstream is simply absent). Pass
|
|
508
|
+
# `public_tool_list: false` for an upstream whose tool list varies by caller
|
|
509
|
+
# privilege, to opt it out of the shared list cache.
|
|
510
|
+
#
|
|
511
|
+
# c.register_upstream(key: "notifications", url: ENV["NOTIFICATIONS_SERVER_URL"])
|
|
512
|
+
def register_upstream(key:, url:, public_tool_list: true)
|
|
513
|
+
upstreams.register(key:, url:, public_tool_list:)
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Declares the gateway's upstreams from an ENV-var map — `{ key => env var
|
|
517
|
+
# name }` — the shape every authority host repeats: resets the registry first
|
|
518
|
+
# (idempotent across code reloads, where the registration typically re-runs in
|
|
519
|
+
# a `to_prepare`), and an upstream whose ENV url is blank is never registered,
|
|
520
|
+
# so an unconfigured environment behaves like no-upstreams. `env` is
|
|
521
|
+
# injectable for tests.
|
|
522
|
+
#
|
|
523
|
+
# config.register_upstreams_from_env("billing" => "BILLING_MCP_URL")
|
|
524
|
+
def register_upstreams_from_env(mapping, env: ENV)
|
|
525
|
+
upstreams.reset!
|
|
526
|
+
mapping.each { |key, env_var| register_upstream(key:, url: env[env_var.to_s]) }
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# The gateway handshake client name, defaulting to the server identity when the
|
|
530
|
+
# host hasn't split it. Read (not stored) so a `server_name` change before the
|
|
531
|
+
# split is set still flows through.
|
|
532
|
+
def gateway_client_name
|
|
533
|
+
@gateway_client_name || server_name
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# The gateway handshake client version, defaulting to the server version.
|
|
537
|
+
def gateway_client_version
|
|
538
|
+
@gateway_client_version || server_version
|
|
182
539
|
end
|
|
183
540
|
|
|
184
541
|
# The serializer base, lazily defaulting to the gem's bundled DSL base. Lazy so
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Hand-rolled JSON-RPC dispatcher for the AUTHORITY + gateway path: it handles a
|
|
4
|
+
# single JSON-RPC request (one element of a batch), serving the host's own tools
|
|
5
|
+
# and, as a gateway, aggregating + proxying upstream MCP servers — WITHOUT the
|
|
6
|
+
# official `mcp` SDK in the request path.
|
|
7
|
+
#
|
|
8
|
+
# The gem carries two dispatch front-ends by design (see McpToolkit::Protocol):
|
|
9
|
+
# this Dispatcher for the authority/gateway endpoint, and McpToolkit::Server.build
|
|
10
|
+
# (the SDK wrapper) for satellites. They are independent; nothing here touches the
|
|
11
|
+
# satellite path.
|
|
12
|
+
#
|
|
13
|
+
# Everything host-specific is injected:
|
|
14
|
+
# * `context` — an McpToolkit::Authority::Context (the resolved account, the
|
|
15
|
+
# authenticated principal, and the bearer token to forward
|
|
16
|
+
# upstream). Re-created PER JSON-RPC request by the transport, so
|
|
17
|
+
# each batch element carries its own account.
|
|
18
|
+
# * `config` — server identity (`server_name`/`server_version`), the
|
|
19
|
+
# negotiable protocol versions, the registered `upstreams`, and
|
|
20
|
+
# the `tool_provider` (the host's api-agnostic tool catalog).
|
|
21
|
+
#
|
|
22
|
+
# The wire behavior — top-level JSON-RPC tool-error codes, `initialize`
|
|
23
|
+
# capabilities `{ tools: { listChanged: true } }`, 3-version negotiation, verbatim
|
|
24
|
+
# upstream error relay, and the custom `notifications/<app>/tools/list_changed`
|
|
25
|
+
# cache-bust — is the byte contract of a first-party endpoint and is preserved
|
|
26
|
+
# exactly.
|
|
27
|
+
class McpToolkit::Dispatcher
|
|
28
|
+
attr_reader :context, :config
|
|
29
|
+
|
|
30
|
+
def initialize(context:, config: McpToolkit.config)
|
|
31
|
+
@context = context
|
|
32
|
+
@config = config
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def handle_request(request)
|
|
36
|
+
dispatch_request(request)
|
|
37
|
+
rescue McpToolkit::Protocol::Error => e
|
|
38
|
+
return nil unless request.key?("id")
|
|
39
|
+
|
|
40
|
+
McpToolkit::Protocol.error_response(id: request["id"], error: e)
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
config.logger&.error("MCP dispatcher error: #{e.message}\n#{e.backtrace&.join("\n")}")
|
|
43
|
+
return nil unless request.key?("id")
|
|
44
|
+
|
|
45
|
+
# A generic message to the caller: the raw exception text of an UNEXPECTED
|
|
46
|
+
# error (ActiveRecord::StatementInvalid carrying SQL, a NoMethodError naming
|
|
47
|
+
# internal classes, an internal hostname) must not leak. Full detail is in
|
|
48
|
+
# the log line above.
|
|
49
|
+
McpToolkit::Protocol.error_response(
|
|
50
|
+
id: request["id"],
|
|
51
|
+
error: McpToolkit::Protocol::InternalError.new("Internal error")
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# Happy path; raises here are turned into JSON-RPC errors by handle_request.
|
|
58
|
+
def dispatch_request(request)
|
|
59
|
+
validate_request!(request)
|
|
60
|
+
|
|
61
|
+
result = dispatch_method(request["method"], request["params"] || {})
|
|
62
|
+
|
|
63
|
+
# JSON-RPC 2.0: notifications (requests without `id`) MUST NOT receive a response.
|
|
64
|
+
return nil unless request.key?("id")
|
|
65
|
+
|
|
66
|
+
McpToolkit::Protocol.success_response(id: request["id"], result:)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def validate_request!(request)
|
|
70
|
+
unless request["jsonrpc"] == McpToolkit::Protocol::JSONRPC_VERSION
|
|
71
|
+
raise McpToolkit::Protocol::InvalidRequest, "Missing jsonrpc version"
|
|
72
|
+
end
|
|
73
|
+
raise McpToolkit::Protocol::InvalidRequest, "Missing method" if request["method"].blank?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def dispatch_method(method, params)
|
|
77
|
+
case method
|
|
78
|
+
when "initialize"
|
|
79
|
+
handle_initialize(params)
|
|
80
|
+
when "initialized", "notifications/initialized"
|
|
81
|
+
handle_initialized
|
|
82
|
+
when "tools/list"
|
|
83
|
+
handle_tools_list
|
|
84
|
+
when "tools/call"
|
|
85
|
+
handle_tools_call(params)
|
|
86
|
+
when "ping"
|
|
87
|
+
handle_ping
|
|
88
|
+
else
|
|
89
|
+
return handle_upstream_list_changed(method) if upstream_list_changed_notification?(method)
|
|
90
|
+
|
|
91
|
+
raise McpToolkit::Protocol::MethodNotFound, method
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Satellites can tell the authority their tool list changed via a
|
|
96
|
+
# `notifications/<app>/tools/list_changed` notification, busting that upstream's
|
|
97
|
+
# cached aggregate. Matches the configured upstream keys only.
|
|
98
|
+
def upstream_list_changed_notification?(method)
|
|
99
|
+
upstream_key_from_notification(method).present?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def handle_upstream_list_changed(method)
|
|
103
|
+
McpToolkit::Gateway::Aggregator.new(config:).flush!(upstream_key_from_notification(method))
|
|
104
|
+
{}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def upstream_key_from_notification(method)
|
|
108
|
+
match = method.to_s.match(%r{\Anotifications/(?<key>.+)/tools/list_changed\z})
|
|
109
|
+
return nil unless match
|
|
110
|
+
|
|
111
|
+
config.upstreams.find(match[:key]) ? match[:key] : nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def handle_initialize(params)
|
|
115
|
+
requested = params["protocolVersion"].to_s
|
|
116
|
+
versions = config.supported_protocol_versions
|
|
117
|
+
negotiated = versions.include?(requested) ? requested : versions.first
|
|
118
|
+
|
|
119
|
+
result = {
|
|
120
|
+
protocolVersion: negotiated,
|
|
121
|
+
capabilities: {
|
|
122
|
+
# listChanged: true — the aggregated list includes upstream tools, which
|
|
123
|
+
# can change when an upstream is reconfigured or sends a list_changed
|
|
124
|
+
# notification that busts the cached aggregate.
|
|
125
|
+
tools: { listChanged: true }
|
|
126
|
+
},
|
|
127
|
+
serverInfo: {
|
|
128
|
+
name: config.server_name,
|
|
129
|
+
version: config.server_version
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
# `instructions` is advertised on initialize only when configured, matching the
|
|
134
|
+
# SDK-backed satellite server (McpToolkit::Server.build) and the documented
|
|
135
|
+
# contract. Omitted when nil to keep the envelope clean.
|
|
136
|
+
result[:instructions] = config.server_instructions if config.server_instructions
|
|
137
|
+
result
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def handle_initialized
|
|
141
|
+
{}
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def handle_ping
|
|
145
|
+
{}
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def handle_tools_list
|
|
149
|
+
{
|
|
150
|
+
tools: host_tool_definitions +
|
|
151
|
+
McpToolkit::Gateway::Aggregator.new(config:).tool_definitions(bearer_token: context.bearer_token)
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# The host's own tool definitions, sourced from the injected tool_provider (the
|
|
156
|
+
# api-agnostic seam). `context` lets the provider hide superuser-only tools from
|
|
157
|
+
# a non-superuser caller. A host that registered no provider contributes none.
|
|
158
|
+
def host_tool_definitions
|
|
159
|
+
provider = config.tool_provider
|
|
160
|
+
return [] unless provider
|
|
161
|
+
|
|
162
|
+
provider.tool_definitions(context)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def handle_tools_call(params)
|
|
166
|
+
tool_name = params["name"]
|
|
167
|
+
arguments = params["arguments"] || {}
|
|
168
|
+
|
|
169
|
+
upstream = config.upstreams.split_tool_name(tool_name)
|
|
170
|
+
return handle_upstream_tools_call(upstream, arguments) if upstream
|
|
171
|
+
|
|
172
|
+
handle_host_tools_call(tool_name, arguments)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def handle_host_tools_call(tool_name, arguments)
|
|
176
|
+
tool = config.tool_provider&.find(tool_name)
|
|
177
|
+
raise McpToolkit::Protocol::MethodNotFound, "Tool not found: #{tool_name}" unless tool
|
|
178
|
+
|
|
179
|
+
ensure_tool_scope!(tool)
|
|
180
|
+
|
|
181
|
+
result = tool.call(context:, **symbolized_arguments(arguments))
|
|
182
|
+
|
|
183
|
+
{
|
|
184
|
+
content: [
|
|
185
|
+
{
|
|
186
|
+
type: "text",
|
|
187
|
+
text: result.is_a?(String) ? result : result.to_json
|
|
188
|
+
}
|
|
189
|
+
]
|
|
190
|
+
}
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# JSON gives string keys; a tool's `call(context:, **arguments)` needs symbol
|
|
194
|
+
# keys for the keyword splat. Deep-symbolized so nested argument hashes reach
|
|
195
|
+
# the tool in the same shape a symbol-keyed caller would pass.
|
|
196
|
+
#
|
|
197
|
+
# `context` is stripped: a tool is invoked as `call(context:, **arguments)`, and
|
|
198
|
+
# a splatted keyword OVERRIDES the explicit one, so a caller-supplied `context`
|
|
199
|
+
# argument would replace the gem-resolved Authority::Context with attacker JSON
|
|
200
|
+
# (auth-context injection). `context` is never a legitimate tool argument.
|
|
201
|
+
def symbolized_arguments(arguments)
|
|
202
|
+
arguments.to_h.deep_symbolize_keys.except(:context)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def ensure_tool_scope!(tool)
|
|
206
|
+
required_scope = tool.required_permissions_scope
|
|
207
|
+
return if required_scope.blank?
|
|
208
|
+
return if context.principal&.authorized_for_scope?(required_scope)
|
|
209
|
+
|
|
210
|
+
raise McpToolkit::Protocol::InvalidRequest, "This token lacks the #{required_scope.inspect} scope"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def handle_upstream_tools_call((app_key, bare_tool_name), arguments)
|
|
214
|
+
McpToolkit::Gateway::Proxy.new(
|
|
215
|
+
app_key:,
|
|
216
|
+
tool_name: bare_tool_name,
|
|
217
|
+
account_id: context.account&.id,
|
|
218
|
+
bearer_token: context.bearer_token,
|
|
219
|
+
config:
|
|
220
|
+
).call(arguments)
|
|
221
|
+
rescue McpToolkit::Gateway::UnknownUpstream => e
|
|
222
|
+
# The gateway stays transport-agnostic; the dispatcher maps an unknown
|
|
223
|
+
# upstream to its own "method not found".
|
|
224
|
+
raise McpToolkit::Protocol::MethodNotFound, e.message
|
|
225
|
+
rescue McpToolkit::Gateway::UpstreamCallError => e
|
|
226
|
+
raise translate_upstream_call_error(e)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Translates the gateway's transport-agnostic upstream failure into the JSON-RPC
|
|
230
|
+
# error shape — verbatim relay of a satellite JSON-RPC error, else a generic
|
|
231
|
+
# internal error.
|
|
232
|
+
def translate_upstream_call_error(error)
|
|
233
|
+
if error.jsonrpc_error
|
|
234
|
+
McpToolkit::Protocol::Error.new(
|
|
235
|
+
error.jsonrpc_error["message"].to_s,
|
|
236
|
+
code: error.jsonrpc_error["code"] || McpToolkit::Protocol::ErrorCodes::INTERNAL_ERROR,
|
|
237
|
+
data: error.jsonrpc_error["data"]
|
|
238
|
+
)
|
|
239
|
+
else
|
|
240
|
+
# A TRANSPORT failure (no upstream JSON-RPC error): its message embeds the
|
|
241
|
+
# internal upstream host:port (Faraday/Net::HTTP "Failed to open TCP
|
|
242
|
+
# connection to <host>:<port>"). The proxy already logged the detail
|
|
243
|
+
# (Gateway::Proxy#log_proxied_failure), so relay only a generic error —
|
|
244
|
+
# never the internal topology — to the caller.
|
|
245
|
+
McpToolkit::Protocol::InternalError.new("Internal error")
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|