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
|
@@ -0,0 +1,234 @@
|
|
|
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
|
+
McpToolkit::Protocol.error_response(
|
|
46
|
+
id: request["id"],
|
|
47
|
+
error: McpToolkit::Protocol::InternalError.new(e.message)
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# Happy path; raises here are turned into JSON-RPC errors by handle_request.
|
|
54
|
+
def dispatch_request(request)
|
|
55
|
+
validate_request!(request)
|
|
56
|
+
|
|
57
|
+
result = dispatch_method(request["method"], request["params"] || {})
|
|
58
|
+
|
|
59
|
+
# JSON-RPC 2.0: notifications (requests without `id`) MUST NOT receive a response.
|
|
60
|
+
return nil unless request.key?("id")
|
|
61
|
+
|
|
62
|
+
McpToolkit::Protocol.success_response(id: request["id"], result:)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def validate_request!(request)
|
|
66
|
+
unless request["jsonrpc"] == McpToolkit::Protocol::JSONRPC_VERSION
|
|
67
|
+
raise McpToolkit::Protocol::InvalidRequest, "Missing jsonrpc version"
|
|
68
|
+
end
|
|
69
|
+
raise McpToolkit::Protocol::InvalidRequest, "Missing method" if request["method"].blank?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def dispatch_method(method, params)
|
|
73
|
+
case method
|
|
74
|
+
when "initialize"
|
|
75
|
+
handle_initialize(params)
|
|
76
|
+
when "initialized", "notifications/initialized"
|
|
77
|
+
handle_initialized
|
|
78
|
+
when "tools/list"
|
|
79
|
+
handle_tools_list
|
|
80
|
+
when "tools/call"
|
|
81
|
+
handle_tools_call(params)
|
|
82
|
+
when "ping"
|
|
83
|
+
handle_ping
|
|
84
|
+
else
|
|
85
|
+
return handle_upstream_list_changed(method) if upstream_list_changed_notification?(method)
|
|
86
|
+
|
|
87
|
+
raise McpToolkit::Protocol::MethodNotFound, method
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Satellites can tell the authority their tool list changed via a
|
|
92
|
+
# `notifications/<app>/tools/list_changed` notification, busting that upstream's
|
|
93
|
+
# cached aggregate. Matches the configured upstream keys only.
|
|
94
|
+
def upstream_list_changed_notification?(method)
|
|
95
|
+
upstream_key_from_notification(method).present?
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def handle_upstream_list_changed(method)
|
|
99
|
+
McpToolkit::Gateway::Aggregator.new(config:).flush!(upstream_key_from_notification(method))
|
|
100
|
+
{}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def upstream_key_from_notification(method)
|
|
104
|
+
match = method.to_s.match(%r{\Anotifications/(?<key>.+)/tools/list_changed\z})
|
|
105
|
+
return nil unless match
|
|
106
|
+
|
|
107
|
+
config.upstreams.find(match[:key]) ? match[:key] : nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def handle_initialize(params)
|
|
111
|
+
requested = params["protocolVersion"].to_s
|
|
112
|
+
versions = config.supported_protocol_versions
|
|
113
|
+
negotiated = versions.include?(requested) ? requested : versions.first
|
|
114
|
+
|
|
115
|
+
result = {
|
|
116
|
+
protocolVersion: negotiated,
|
|
117
|
+
capabilities: {
|
|
118
|
+
# listChanged: true — the aggregated list includes upstream tools, which
|
|
119
|
+
# can change when an upstream is reconfigured or sends a list_changed
|
|
120
|
+
# notification that busts the cached aggregate.
|
|
121
|
+
tools: { listChanged: true }
|
|
122
|
+
},
|
|
123
|
+
serverInfo: {
|
|
124
|
+
name: config.server_name,
|
|
125
|
+
version: config.server_version
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
# `instructions` is advertised on initialize only when configured, matching the
|
|
130
|
+
# SDK-backed satellite server (McpToolkit::Server.build) and the documented
|
|
131
|
+
# contract. Omitted when nil to keep the envelope clean.
|
|
132
|
+
result[:instructions] = config.server_instructions if config.server_instructions
|
|
133
|
+
result
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def handle_initialized
|
|
137
|
+
{}
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def handle_ping
|
|
141
|
+
{}
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def handle_tools_list
|
|
145
|
+
{
|
|
146
|
+
tools: host_tool_definitions +
|
|
147
|
+
McpToolkit::Gateway::Aggregator.new(config:).tool_definitions(bearer_token: context.bearer_token)
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The host's own tool definitions, sourced from the injected tool_provider (the
|
|
152
|
+
# api-agnostic seam). `context` lets the provider hide superuser-only tools from
|
|
153
|
+
# a non-superuser caller. A host that registered no provider contributes none.
|
|
154
|
+
def host_tool_definitions
|
|
155
|
+
provider = config.tool_provider
|
|
156
|
+
return [] unless provider
|
|
157
|
+
|
|
158
|
+
provider.tool_definitions(context)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def handle_tools_call(params)
|
|
162
|
+
tool_name = params["name"]
|
|
163
|
+
arguments = params["arguments"] || {}
|
|
164
|
+
|
|
165
|
+
upstream = config.upstreams.split_tool_name(tool_name)
|
|
166
|
+
return handle_upstream_tools_call(upstream, arguments) if upstream
|
|
167
|
+
|
|
168
|
+
handle_host_tools_call(tool_name, arguments)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def handle_host_tools_call(tool_name, arguments)
|
|
172
|
+
tool = config.tool_provider&.find(tool_name)
|
|
173
|
+
raise McpToolkit::Protocol::MethodNotFound, "Tool not found: #{tool_name}" unless tool
|
|
174
|
+
|
|
175
|
+
ensure_tool_scope!(tool)
|
|
176
|
+
|
|
177
|
+
result = tool.call(context:, **symbolized_arguments(arguments))
|
|
178
|
+
|
|
179
|
+
{
|
|
180
|
+
content: [
|
|
181
|
+
{
|
|
182
|
+
type: "text",
|
|
183
|
+
text: result.is_a?(String) ? result : result.to_json
|
|
184
|
+
}
|
|
185
|
+
]
|
|
186
|
+
}
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# JSON gives string keys; a tool's `call(context:, **arguments)` needs symbol
|
|
190
|
+
# keys for the keyword splat. Deep-symbolized so nested argument hashes reach
|
|
191
|
+
# the tool in the same shape a symbol-keyed caller would pass.
|
|
192
|
+
def symbolized_arguments(arguments)
|
|
193
|
+
arguments.to_h.deep_symbolize_keys
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def ensure_tool_scope!(tool)
|
|
197
|
+
required_scope = tool.required_permissions_scope
|
|
198
|
+
return if required_scope.blank?
|
|
199
|
+
return if context.principal&.authorized_for_scope?(required_scope)
|
|
200
|
+
|
|
201
|
+
raise McpToolkit::Protocol::InvalidRequest, "This token lacks the #{required_scope.inspect} scope"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def handle_upstream_tools_call((app_key, bare_tool_name), arguments)
|
|
205
|
+
McpToolkit::Gateway::Proxy.new(
|
|
206
|
+
app_key:,
|
|
207
|
+
tool_name: bare_tool_name,
|
|
208
|
+
account_id: context.account&.id,
|
|
209
|
+
bearer_token: context.bearer_token,
|
|
210
|
+
config:
|
|
211
|
+
).call(arguments)
|
|
212
|
+
rescue McpToolkit::Gateway::UnknownUpstream => e
|
|
213
|
+
# The gateway stays transport-agnostic; the dispatcher maps an unknown
|
|
214
|
+
# upstream to its own "method not found".
|
|
215
|
+
raise McpToolkit::Protocol::MethodNotFound, e.message
|
|
216
|
+
rescue McpToolkit::Gateway::UpstreamCallError => e
|
|
217
|
+
raise translate_upstream_call_error(e)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Translates the gateway's transport-agnostic upstream failure into the JSON-RPC
|
|
221
|
+
# error shape — verbatim relay of a satellite JSON-RPC error, else a generic
|
|
222
|
+
# internal error.
|
|
223
|
+
def translate_upstream_call_error(error)
|
|
224
|
+
if error.jsonrpc_error
|
|
225
|
+
McpToolkit::Protocol::Error.new(
|
|
226
|
+
error.jsonrpc_error["message"].to_s,
|
|
227
|
+
code: error.jsonrpc_error["code"] || McpToolkit::Protocol::ErrorCodes::INTERNAL_ERROR,
|
|
228
|
+
data: error.jsonrpc_error["data"]
|
|
229
|
+
)
|
|
230
|
+
else
|
|
231
|
+
McpToolkit::Protocol::InternalError.new(error.message)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
data/lib/mcp_toolkit/engine.rb
CHANGED
|
@@ -1,21 +1,36 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Mountable Rails engine that draws the
|
|
4
|
-
# engine's config/routes.rb so they survive
|
|
5
|
-
# gem-provided McpToolkit::ServerController
|
|
3
|
+
# Mountable Rails engine that draws the MCP transport routes plus the authority
|
|
4
|
+
# introspection route (defined in the engine's config/routes.rb so they survive
|
|
5
|
+
# Rails' route reloads) against the gem-provided McpToolkit::ServerController /
|
|
6
|
+
# McpToolkit::TokensController. A satellite mounts it in one line:
|
|
6
7
|
#
|
|
7
8
|
# # config/routes.rb
|
|
8
9
|
# mount McpToolkit::Engine => "/mcp"
|
|
9
10
|
#
|
|
10
11
|
# yielding exactly the endpoints a hand-rolled satellite declared:
|
|
11
12
|
#
|
|
12
|
-
# POST /mcp
|
|
13
|
-
# GET /mcp
|
|
14
|
-
# DELETE /mcp
|
|
15
|
-
# GET /mcp/health
|
|
13
|
+
# POST /mcp -> create (JSON-RPC requests/responses)
|
|
14
|
+
# GET /mcp -> stream (405; no server-initiated SSE)
|
|
15
|
+
# DELETE /mcp -> destroy (terminate the session)
|
|
16
|
+
# GET /mcp/health -> health (unauthenticated probe)
|
|
17
|
+
# POST /mcp/tokens/introspect -> introspect (authority token introspection;
|
|
18
|
+
# drawn ONLY when auth_role is
|
|
19
|
+
# :authority — a satellite that
|
|
20
|
+
# mounts the engine gets no such
|
|
21
|
+
# route)
|
|
16
22
|
#
|
|
17
23
|
# Loaded ONLY when Rails::Engine is available (see lib/mcp_toolkit.rb); the gem's
|
|
18
24
|
# non-Rails consumers and its own unit suite never reference it.
|
|
19
25
|
class McpToolkit::Engine < Rails::Engine
|
|
20
26
|
isolate_namespace McpToolkit
|
|
27
|
+
|
|
28
|
+
# The gem-provided controllers subclass `config.parent_controller`, which the
|
|
29
|
+
# host sets in an initializer/to_prepare that must be READ AFTER it runs
|
|
30
|
+
# (Constraint B). They are therefore built lazily by
|
|
31
|
+
# `McpToolkit.build_engine_controllers!` (triggered via const_missing on first
|
|
32
|
+
# reference — at eager-load or first request). This resets them on every code
|
|
33
|
+
# reload so a changed parent (or a reloaded app parent class) takes effect on the
|
|
34
|
+
# next reference. Runs before `:eager_load!`, so the fresh classes exist for it.
|
|
35
|
+
config.to_prepare { McpToolkit.reset_engine_controllers! }
|
|
21
36
|
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Lazy `parent_controller` builder (Constraint B).
|
|
4
|
+
#
|
|
5
|
+
# The engine's controllers (McpToolkit::ServerController,
|
|
6
|
+
# McpToolkit::TokensController) and the authority base
|
|
7
|
+
# (McpToolkit::Authority::ServerController) all subclass
|
|
8
|
+
# `config.parent_controller`. If that superclass were resolved in a class body of
|
|
9
|
+
# an autoloaded/eager-loaded file, it could be read BEFORE the host's
|
|
10
|
+
# initializer/to_prepare had set it — defaulting to ActionController::Base and, in
|
|
11
|
+
# turn, breaking CSRF handling on the introspection endpoint.
|
|
12
|
+
#
|
|
13
|
+
# Instead, none of these controllers is a Zeitwerk-managed file. They are built
|
|
14
|
+
# here from the CURRENT config, and the build is triggered LAZILY:
|
|
15
|
+
# * `const_missing` (below, and on McpToolkit::Authority) builds them the first
|
|
16
|
+
# time they are referenced — which, in a host, is at eager-load or first-
|
|
17
|
+
# request time, i.e. AFTER the app's initializers/to_prepare have run;
|
|
18
|
+
# * the engine's `config.to_prepare` RESETS them on every code reload so a
|
|
19
|
+
# changed `parent_controller` (or a reloaded app parent class) takes effect on
|
|
20
|
+
# the next reference.
|
|
21
|
+
#
|
|
22
|
+
# The whole `config/initializers/mcp_toolkit.rb` of a host can therefore live in
|
|
23
|
+
# `to_prepare`: the parent is only ever read at build time.
|
|
24
|
+
#
|
|
25
|
+
# This file reopens `McpToolkit` to add module methods, so it is Zeitwerk-IGNORED
|
|
26
|
+
# (like engine.rb) and required explicitly from the gem entry point.
|
|
27
|
+
module McpToolkit
|
|
28
|
+
# The controllers built directly under McpToolkit (the engine's routes point at
|
|
29
|
+
# these). McpToolkit::Authority::ServerController is built alongside them but is
|
|
30
|
+
# fetched through McpToolkit::Authority's own const_missing.
|
|
31
|
+
ENGINE_CONTROLLER_NAMES = %i[ServerController TokensController].freeze
|
|
32
|
+
|
|
33
|
+
# (Re)builds the engine controllers + the authority base from the current
|
|
34
|
+
# config. Idempotent: an existing constant is replaced so a rebuild reflects a
|
|
35
|
+
# changed `parent_controller`. Reads `config.parent_controller` at call time.
|
|
36
|
+
def self.build_engine_controllers!
|
|
37
|
+
parent = config.parent_controller.constantize
|
|
38
|
+
define_controller(self, :ServerController, build_server_controller(parent))
|
|
39
|
+
define_controller(self, :TokensController, build_tokens_controller(parent))
|
|
40
|
+
define_controller(Authority, :ServerController, build_authority_server_controller(parent))
|
|
41
|
+
ServerController
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Undefines the built controllers so the next reference rebuilds them from the
|
|
45
|
+
# then-current config. Called from the engine's `to_prepare` on every reload.
|
|
46
|
+
def self.reset_engine_controllers!
|
|
47
|
+
[[self, :ServerController], [self, :TokensController], [Authority, :ServerController]].each do |mod, name|
|
|
48
|
+
mod.send(:remove_const, name) if mod.const_defined?(name, false)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The SATELLITE transport controller the engine mounts (unchanged behavior; the
|
|
53
|
+
# SDK-backed path). Built lazily only so its parent is read after config.
|
|
54
|
+
def self.build_server_controller(parent)
|
|
55
|
+
Class.new(parent) { include McpToolkit::Transport::ControllerMethods }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The AUTHORITY introspection endpoint the engine mounts at
|
|
59
|
+
# `POST /mcp/tokens/introspect`. Behavior is preserved exactly: it authenticates
|
|
60
|
+
# the bearer against `config.token_authenticator` (via Auth::Authority) and
|
|
61
|
+
# renders the introspection payload; a non-authority app answers `{ valid:
|
|
62
|
+
# false }` rather than erroring.
|
|
63
|
+
def self.build_tokens_controller(parent)
|
|
64
|
+
Class.new(parent) do
|
|
65
|
+
def introspect
|
|
66
|
+
token = McpToolkit::Auth::Authority.authenticate(mcp_extract_token, config: mcp_config)
|
|
67
|
+
return render(json: McpToolkit::Auth::Authority.invalid_payload, status: :unauthorized) if token.nil?
|
|
68
|
+
|
|
69
|
+
render json: McpToolkit::Auth::Authority.introspection_payload(token)
|
|
70
|
+
rescue McpToolkit::Errors::ConfigurationError
|
|
71
|
+
# Not configured as an authority (no token_authenticator): behave as if the
|
|
72
|
+
# token were invalid instead of surfacing a 500.
|
|
73
|
+
render json: McpToolkit::Auth::Authority.invalid_payload, status: :unauthorized
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def mcp_config
|
|
79
|
+
McpToolkit.config
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def mcp_extract_token
|
|
83
|
+
auth_header = request.headers["Authorization"]
|
|
84
|
+
return auth_header.sub("Bearer ", "") if auth_header&.start_with?("Bearer ")
|
|
85
|
+
|
|
86
|
+
request.headers["X-MCP-Token"].presence || params[:token].presence
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The AUTHORITY base controller a host subclasses (the recommended path for a
|
|
92
|
+
# host whose rate-limit/usage/account hooks touch app models).
|
|
93
|
+
def self.build_authority_server_controller(parent)
|
|
94
|
+
Class.new(parent) { include McpToolkit::Authority::ControllerMethods }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Removes an existing same-named constant (avoiding a redefinition warning on a
|
|
98
|
+
# rebuild) before setting the freshly-built class.
|
|
99
|
+
def self.define_controller(mod, name, klass)
|
|
100
|
+
mod.send(:remove_const, name) if mod.const_defined?(name, false)
|
|
101
|
+
mod.const_set(name, klass)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Backstop: build the engine controllers the first time one is referenced before
|
|
105
|
+
# any `to_prepare`/eager-load pass has built them (e.g. a bespoke route that
|
|
106
|
+
# names McpToolkit::ServerController directly). McpToolkit::Authority defines the
|
|
107
|
+
# sibling backstop for its ServerController.
|
|
108
|
+
def self.const_missing(name)
|
|
109
|
+
if ENGINE_CONTROLLER_NAMES.include?(name)
|
|
110
|
+
build_engine_controllers!
|
|
111
|
+
return const_get(name) if const_defined?(name, false)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
super
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
5
|
+
# Aggregates the tool lists of all configured upstream MCP servers for a
|
|
6
|
+
# gateway's `tools/list`, namespacing each tool as `<app>__<tool>`.
|
|
7
|
+
#
|
|
8
|
+
# Per upstream the namespaced list is cached in `config.cache_store`
|
|
9
|
+
# (`config.upstream_list_ttl`, default 15 min) and bustable via `flush!` /
|
|
10
|
+
# `flush!(key)`. On a cache miss the live pull runs per-upstream (one HTTP call
|
|
11
|
+
# each); a failing/timeout upstream is omitted + logged, never breaking the list.
|
|
12
|
+
#
|
|
13
|
+
# Why the cache is safe to share globally: an upstream's `tools/list` is
|
|
14
|
+
# token-INDEPENDENT — it returns the same public tool definitions to every valid
|
|
15
|
+
# caller and enforces scope only when a tool is CALLED (per-call authorization is
|
|
16
|
+
# the upstream's job). So one caller's pull is a correct answer for all callers.
|
|
17
|
+
# This is a registration CONTRACT, not an assumption: an upstream that filters its
|
|
18
|
+
# list by the caller's privilege (e.g. hides superuser-only tools) MUST register
|
|
19
|
+
# `public_tool_list: false`, which opts it out of this cache (pulled live per
|
|
20
|
+
# request) so a privileged caller's list can never be served to an unprivileged
|
|
21
|
+
# one. See McpToolkit::Gateway::UpstreamRegistry::Upstream.
|
|
22
|
+
#
|
|
23
|
+
# Only a NON-EMPTY pull is ever cached. An empty or failed pull is almost always a
|
|
24
|
+
# transient upstream hiccup (timeout, a session/handshake blip, a degenerate 200);
|
|
25
|
+
# caching it would freeze a whole app's tools out of `tools/list` for the full TTL
|
|
26
|
+
# for EVERY caller (a poisoned global cache), which is exactly the failure this
|
|
27
|
+
# guards against. A stale empty already in the cache is treated as a miss and
|
|
28
|
+
# re-pulled, so the aggregate self-heals as soon as the upstream returns tools.
|
|
29
|
+
#
|
|
30
|
+
# Concurrency: upstreams are pulled CONCURRENTLY via concurrent-ruby futures. When
|
|
31
|
+
# running inside Rails, each future is wrapped in `Rails.application.executor` so
|
|
32
|
+
# it participates in the framework's per-request lifecycle (reloading, query
|
|
33
|
+
# cache, connection checkout); a non-Rails host runs plain futures. Output order
|
|
34
|
+
# follows the registry order.
|
|
35
|
+
class McpToolkit::Gateway::Aggregator
|
|
36
|
+
CACHE_KEY_PREFIX = "mcp_toolkit:gateway:tools:"
|
|
37
|
+
|
|
38
|
+
def initialize(config: McpToolkit.config)
|
|
39
|
+
@config = config
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Namespaced tool definitions across all configured upstreams. `bearer_token`
|
|
43
|
+
# is used only on a cache miss, so the upstream can authenticate the list
|
|
44
|
+
# request the same way it would a call. Upstreams are fetched concurrently;
|
|
45
|
+
# output order follows the registry order.
|
|
46
|
+
def tool_definitions(bearer_token: nil)
|
|
47
|
+
futures = config.upstreams.all.map do |upstream|
|
|
48
|
+
Concurrent::Promises.future do
|
|
49
|
+
within_executor { cached_or_live_definitions(upstream, bearer_token:) }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
futures.flat_map(&:value!)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def flush!(key = nil)
|
|
57
|
+
if key
|
|
58
|
+
cache.delete(cache_key(key))
|
|
59
|
+
else
|
|
60
|
+
config.upstreams.all.each { |upstream| cache.delete(cache_key(upstream.key)) }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
attr_reader :config
|
|
67
|
+
|
|
68
|
+
# Runs the block inside the Rails executor when a BOOTED Rails app is present
|
|
69
|
+
# (so a future participates in the request lifecycle: reloading, query cache,
|
|
70
|
+
# connection checkout), or plainly otherwise. Guards for `Rails` being defined
|
|
71
|
+
# but not booted (e.g. `rails/version` required without an initialized app), in
|
|
72
|
+
# which case `Rails.application` is nil and we run the block directly.
|
|
73
|
+
def within_executor(&)
|
|
74
|
+
if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
75
|
+
Rails.application.executor.wrap(&)
|
|
76
|
+
else
|
|
77
|
+
yield
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def cached_or_live_definitions(upstream, bearer_token:)
|
|
82
|
+
# A caller-dependent upstream opts out of the shared cache (keyed by upstream
|
|
83
|
+
# only): caching it would leak one caller's list to callers of other privilege.
|
|
84
|
+
return live_definitions(upstream, bearer_token:) unless upstream.public_tool_list
|
|
85
|
+
|
|
86
|
+
cached = cache.read(cache_key(upstream.key))
|
|
87
|
+
# `present?` treats both a nil miss AND a stale empty list as "no cache", so a
|
|
88
|
+
# previously poisoned empty entry is re-pulled instead of served.
|
|
89
|
+
return cached if cached.present?
|
|
90
|
+
|
|
91
|
+
definitions = live_definitions(upstream, bearer_token:)
|
|
92
|
+
# Only persist a real, non-empty list; never cache an empty/degraded pull.
|
|
93
|
+
cache.write(cache_key(upstream.key), definitions, expires_in: config.upstream_list_ttl) if definitions.present?
|
|
94
|
+
definitions
|
|
95
|
+
rescue McpToolkit::Gateway::Client::Error => e
|
|
96
|
+
# Degrade gracefully: omit this upstream's tools, don't cache the failure.
|
|
97
|
+
config.logger&.error("MCP upstream #{upstream.key} tools/list failed, omitting: #{e.message}")
|
|
98
|
+
[]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def live_definitions(upstream, bearer_token:)
|
|
102
|
+
client = McpToolkit::Gateway::Client.new(upstream:, bearer_token:, config:)
|
|
103
|
+
client.tools_list.map do |definition|
|
|
104
|
+
namespaced(upstream, definition)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Re-keys an upstream tool definition into the gateway's aggregate namespace.
|
|
109
|
+
def namespaced(upstream, definition)
|
|
110
|
+
definition = definition.dup
|
|
111
|
+
definition["name"] = upstream.name_for(definition["name"])
|
|
112
|
+
definition
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def cache
|
|
116
|
+
config.cache_store
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def cache_key(key)
|
|
120
|
+
"#{CACHE_KEY_PREFIX}#{key}"
|
|
121
|
+
end
|
|
122
|
+
end
|