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,305 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
|
|
5
|
+
# Minimal MCP client over Streamable HTTP, used by a GATEWAY to talk to an
|
|
6
|
+
# upstream MCP server when aggregating its tool list and proxying tool calls.
|
|
7
|
+
#
|
|
8
|
+
# It speaks the same Streamable-HTTP MCP that McpToolkit::Transport serves:
|
|
9
|
+
# 1. POST `initialize` -> capture the `Mcp-Session-Id` response header
|
|
10
|
+
# 2. POST `notifications/initialized` (a notification; no response expected)
|
|
11
|
+
# 3. POST `tools/list` / `tools/call`, echoing the session header
|
|
12
|
+
#
|
|
13
|
+
# Auth & account selection are pass-through: the caller's bearer token and the
|
|
14
|
+
# account selector (`_meta`) are forwarded so the upstream can introspect the
|
|
15
|
+
# same token against its authority and resolve the same account.
|
|
16
|
+
#
|
|
17
|
+
# Transport notes
|
|
18
|
+
# - Content negotiation: we POST application/json and Accept both
|
|
19
|
+
# application/json and text/event-stream. If the upstream answers with SSE
|
|
20
|
+
# (one message + EOF, as the toolkit's own transport does) we extract the
|
|
21
|
+
# single JSON payload from the `data:` line.
|
|
22
|
+
# - Every public method may raise McpToolkit::Gateway::Client::Error (timeouts,
|
|
23
|
+
# non-2xx, unparseable bodies, JSON-RPC errors). Callers decide whether to
|
|
24
|
+
# degrade (omit from list) or surface the error (proxied call).
|
|
25
|
+
#
|
|
26
|
+
# Everything app-specific (server identity, protocol version, timeout, logger) is
|
|
27
|
+
# injected via McpToolkit::Configuration; nothing here names a deployment.
|
|
28
|
+
class McpToolkit::Gateway::Client
|
|
29
|
+
SESSION_HEADER = "Mcp-Session-Id"
|
|
30
|
+
JSONRPC_VERSION = "2.0"
|
|
31
|
+
|
|
32
|
+
# The protocol version offered on the handshake when the config does not pin
|
|
33
|
+
# one. Sourced from the wrapped `mcp` SDK's latest-supported constant when
|
|
34
|
+
# available, with a literal fallback so the gem still loads if the SDK moves the
|
|
35
|
+
# constant. `config.protocol_version` overrides it.
|
|
36
|
+
DEFAULT_PROTOCOL_VERSION =
|
|
37
|
+
if defined?(MCP::Configuration) && MCP::Configuration.const_defined?(:LATEST_STABLE_PROTOCOL_VERSION)
|
|
38
|
+
MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION
|
|
39
|
+
else
|
|
40
|
+
"2025-06-18"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# An upstream whose session store is not shared across its pods can answer a
|
|
44
|
+
# NON-initialize request with HTTP 404 (the pod fielding it never saw our
|
|
45
|
+
# initialize), or with a JSON-RPC -32001 "Session not found or expired". Both
|
|
46
|
+
# mean the same thing: our session is gone and a fresh handshake recovers it.
|
|
47
|
+
SESSION_LOSS_HTTP_STATUS = 404
|
|
48
|
+
SESSION_NOT_FOUND_CODE = -32_001
|
|
49
|
+
|
|
50
|
+
# Raised for any upstream failure. `jsonrpc_error` carries an upstream JSON-RPC
|
|
51
|
+
# error hash when the failure was a protocol-level error response, so a proxy
|
|
52
|
+
# can relay it verbatim; nil for transport/HTTP failures. `http_status` carries
|
|
53
|
+
# the HTTP status for a non-2xx response (nil otherwise), so callers can
|
|
54
|
+
# distinguish a session-loss 404 from other failures. This class references NO
|
|
55
|
+
# transport/protocol-error type — the consumer maps it.
|
|
56
|
+
class Error < McpToolkit::Error
|
|
57
|
+
attr_reader :jsonrpc_error, :http_status
|
|
58
|
+
|
|
59
|
+
def initialize(message, jsonrpc_error: nil, http_status: nil)
|
|
60
|
+
super(message)
|
|
61
|
+
@jsonrpc_error = jsonrpc_error
|
|
62
|
+
@http_status = http_status
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
attr_reader :upstream, :bearer_token, :timeout, :config
|
|
67
|
+
|
|
68
|
+
def initialize(upstream:, bearer_token: nil, config: McpToolkit.config)
|
|
69
|
+
@upstream = upstream
|
|
70
|
+
@bearer_token = bearer_token
|
|
71
|
+
@config = config
|
|
72
|
+
@timeout = config.upstream_timeout
|
|
73
|
+
@session_id = nil
|
|
74
|
+
@initialized = false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Returns the upstream's raw tools array (each a tool definition hash with
|
|
78
|
+
# string keys: "name", "description", "inputSchema"). Bare names — the caller
|
|
79
|
+
# namespaces them.
|
|
80
|
+
def tools_list
|
|
81
|
+
with_session_recovery("tools/list") do
|
|
82
|
+
result = rpc!("tools/list")
|
|
83
|
+
Array(result["tools"])
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Proxies a tools/call to the upstream. `arguments` and `meta` are forwarded
|
|
88
|
+
# as-is. Returns the upstream's `result` hash verbatim (typically
|
|
89
|
+
# `{ "content" => [...] }`). A JSON-RPC error from the upstream is raised as an
|
|
90
|
+
# Error carrying `jsonrpc_error` so a proxy can relay it.
|
|
91
|
+
def tools_call(name:, arguments: {}, meta: nil)
|
|
92
|
+
params = { "name" => name, "arguments" => arguments }
|
|
93
|
+
params["_meta"] = meta if meta.present?
|
|
94
|
+
with_session_recovery("tools/call #{name}") do
|
|
95
|
+
rpc!("tools/call", params)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# The protocol version to offer on the handshake: the config's pin, else the
|
|
102
|
+
# gem's default.
|
|
103
|
+
def protocol_version
|
|
104
|
+
config.protocol_version || DEFAULT_PROTOCOL_VERSION
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Runs a request-bearing RPC against an initialized session, transparently
|
|
108
|
+
# recovering from a SINGLE session-loss response: if the upstream reports our
|
|
109
|
+
# session is gone (see SESSION_LOSS_HTTP_STATUS / SESSION_NOT_FOUND_CODE) we
|
|
110
|
+
# drop the dead session, re-handshake, and retry the block exactly ONCE. Any
|
|
111
|
+
# other error — a genuine JSON-RPC tool error, a timeout, a non-404 HTTP
|
|
112
|
+
# failure — propagates verbatim, unchanged. An upstream tool that returns an
|
|
113
|
+
# `isError` content result is a normal return value here (not an exception), so
|
|
114
|
+
# it passes straight through.
|
|
115
|
+
def with_session_recovery(operation, &)
|
|
116
|
+
ensure_initialized!
|
|
117
|
+
yield
|
|
118
|
+
rescue Error => e
|
|
119
|
+
raise unless session_loss?(e)
|
|
120
|
+
|
|
121
|
+
recover_and_retry(operation, e, &)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Second (and final) attempt after a session-loss signal: re-establish the
|
|
125
|
+
# session and run the block once more. Bounded to one retry — this method does
|
|
126
|
+
# not recurse into `with_session_recovery`, so it cannot loop. If re-init or the
|
|
127
|
+
# retry itself fails, the error is clarified before it propagates.
|
|
128
|
+
def recover_and_retry(operation, original_error)
|
|
129
|
+
log_session_recovery(operation, original_error)
|
|
130
|
+
reset_session!
|
|
131
|
+
ensure_initialized!
|
|
132
|
+
yield
|
|
133
|
+
rescue Error => e
|
|
134
|
+
raise clarified_session_error(e, operation)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Performs the initialize handshake once per client instance, capturing the
|
|
138
|
+
# session id, then sends the `notifications/initialized` notification.
|
|
139
|
+
def ensure_initialized!
|
|
140
|
+
return if @initialized
|
|
141
|
+
|
|
142
|
+
response = post_jsonrpc(
|
|
143
|
+
jsonrpc_request("initialize", {
|
|
144
|
+
"protocolVersion" => protocol_version,
|
|
145
|
+
"capabilities" => {},
|
|
146
|
+
# The GATEWAY-client identity (falls back to the server
|
|
147
|
+
# identity), split so an authority can advertise its own
|
|
148
|
+
# server_name to callers while keeping this upstream
|
|
149
|
+
# handshake byte-identical.
|
|
150
|
+
"clientInfo" => {
|
|
151
|
+
"name" => config.gateway_client_name,
|
|
152
|
+
"version" => config.gateway_client_version
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
)
|
|
156
|
+
@session_id = response.headers[SESSION_HEADER].presence
|
|
157
|
+
parse_rpc_result!(response)
|
|
158
|
+
|
|
159
|
+
# Best-effort lifecycle notification; upstreams may ignore it. A notification
|
|
160
|
+
# returns no body (202), so we don't parse a result.
|
|
161
|
+
post_jsonrpc(jsonrpc_notification("notifications/initialized"))
|
|
162
|
+
|
|
163
|
+
@initialized = true
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Drops the current session so the next `ensure_initialized!` re-handshakes.
|
|
167
|
+
def reset_session!
|
|
168
|
+
@initialized = false
|
|
169
|
+
@session_id = nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# True when an upstream error signals our session is gone and a fresh handshake
|
|
173
|
+
# could recover it: a bare HTTP 404, or a JSON-RPC -32001 session error.
|
|
174
|
+
def session_loss?(error)
|
|
175
|
+
error.http_status == SESSION_LOSS_HTTP_STATUS ||
|
|
176
|
+
error.jsonrpc_error&.dig("code") == SESSION_NOT_FOUND_CODE
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# A session-loss failure we could NOT recover from (already retried once).
|
|
180
|
+
# Preserve verbatim relay for a genuine JSON-RPC error and pass through any
|
|
181
|
+
# unrelated failure; only rewrite the bare "returned HTTP 404", whose message
|
|
182
|
+
# hides the real cause.
|
|
183
|
+
def clarified_session_error(error, operation)
|
|
184
|
+
return error if error.jsonrpc_error
|
|
185
|
+
return error unless session_loss?(error)
|
|
186
|
+
|
|
187
|
+
Error.new(
|
|
188
|
+
"upstream #{upstream.key} #{operation} failed: the MCP session was lost and could not " \
|
|
189
|
+
"be re-established after re-initializing the handshake",
|
|
190
|
+
http_status: error.http_status
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def log_session_recovery(operation, error)
|
|
195
|
+
config.logger&.warn(
|
|
196
|
+
"MCP upstream #{upstream.key} #{operation}: session lost (#{session_loss_reason(error)}), " \
|
|
197
|
+
"re-initializing and retrying once"
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def session_loss_reason(error)
|
|
202
|
+
return "JSON-RPC #{error.jsonrpc_error["code"]}" if error.jsonrpc_error
|
|
203
|
+
return "HTTP #{error.http_status}" if error.http_status
|
|
204
|
+
|
|
205
|
+
"unknown"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Sends a request-bearing JSON-RPC call and returns its `result`.
|
|
209
|
+
def rpc!(method, params = {})
|
|
210
|
+
response = post_jsonrpc(jsonrpc_request(method, params))
|
|
211
|
+
parse_rpc_result!(response)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def jsonrpc_request(method, params = {})
|
|
215
|
+
{ "jsonrpc" => JSONRPC_VERSION, "id" => SecureRandom.uuid, "method" => method, "params" => params }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def jsonrpc_notification(method, params = {})
|
|
219
|
+
{ "jsonrpc" => JSONRPC_VERSION, "method" => method, "params" => params }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def post_jsonrpc(body)
|
|
223
|
+
connection.post(upstream.url) do |request|
|
|
224
|
+
apply_request_headers(request.headers)
|
|
225
|
+
request.body = JSON.generate(body)
|
|
226
|
+
end
|
|
227
|
+
rescue Faraday::TimeoutError => e
|
|
228
|
+
raise upstream_error("timed out after #{timeout}s", e)
|
|
229
|
+
rescue Faraday::Error => e
|
|
230
|
+
raise upstream_error("request failed", e)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def upstream_error(reason, cause)
|
|
234
|
+
Error.new("upstream #{upstream.key} #{reason}: #{cause.message}")
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Sets the content-negotiation, auth, and session headers on an outgoing
|
|
238
|
+
# request. Auth and session headers are added only when present.
|
|
239
|
+
def apply_request_headers(headers)
|
|
240
|
+
headers["Content-Type"] = "application/json"
|
|
241
|
+
headers["Accept"] = "application/json, text/event-stream"
|
|
242
|
+
headers["Authorization"] = "Bearer #{bearer_token}" if bearer_token.present?
|
|
243
|
+
headers[SESSION_HEADER] = @session_id if @session_id.present?
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Validates the HTTP response and extracts the JSON-RPC `result`, raising on a
|
|
247
|
+
# JSON-RPC error (with the error hash attached for verbatim relay).
|
|
248
|
+
def parse_rpc_result!(response)
|
|
249
|
+
raise_http_error!(response) unless response.success?
|
|
250
|
+
|
|
251
|
+
payload = decode_body(response)
|
|
252
|
+
return {} if payload.nil? # e.g. 202 Accepted for a notification
|
|
253
|
+
|
|
254
|
+
if payload["error"]
|
|
255
|
+
raise Error.new(
|
|
256
|
+
"upstream #{upstream.key} JSON-RPC error: #{payload.dig("error", "message")}",
|
|
257
|
+
jsonrpc_error: payload["error"]
|
|
258
|
+
)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
payload["result"] || {}
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# The `http_status` is carried on the Error so a session-loss 404 can be told
|
|
265
|
+
# apart from other non-2xx failures (see `session_loss?`).
|
|
266
|
+
def raise_http_error!(response)
|
|
267
|
+
raise Error.new("upstream #{upstream.key} returned HTTP #{response.status}", http_status: response.status)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Decodes either a plain JSON body or a single SSE `data:` frame.
|
|
271
|
+
def decode_body(response)
|
|
272
|
+
json = json_payload(response)
|
|
273
|
+
return nil if json.nil?
|
|
274
|
+
|
|
275
|
+
JSON.parse(json)
|
|
276
|
+
rescue JSON::ParserError => e
|
|
277
|
+
raise Error, "upstream #{upstream.key} returned unparseable body: #{e.message}"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Extracts the JSON text to parse, unwrapping an SSE frame when the response is
|
|
281
|
+
# an event stream. Returns nil for an empty body or an empty/absent frame.
|
|
282
|
+
def json_payload(response)
|
|
283
|
+
body = response.body.to_s
|
|
284
|
+
return nil if body.strip.empty?
|
|
285
|
+
|
|
286
|
+
content_type = response.headers["Content-Type"].to_s
|
|
287
|
+
json = content_type.include?("text/event-stream") ? extract_sse_data(body) : body
|
|
288
|
+
json unless json.nil? || json.strip.empty?
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Pulls the JSON payload from the first `data:` line of an SSE stream.
|
|
292
|
+
def extract_sse_data(body)
|
|
293
|
+
body.each_line.filter_map do |line|
|
|
294
|
+
line.start_with?("data:") ? line.sub(/\Adata:\s?/, "").chomp : nil
|
|
295
|
+
end.first
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def connection
|
|
299
|
+
@connection ||= Faraday.new do |conn|
|
|
300
|
+
conn.options.timeout = timeout
|
|
301
|
+
conn.options.open_timeout = timeout
|
|
302
|
+
conn.adapter Faraday.default_adapter
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Proxies a namespaced upstream tool call (`<app>__<tool>`) to its upstream MCP
|
|
4
|
+
# server.
|
|
5
|
+
#
|
|
6
|
+
# The account selector is forwarded as `_meta[config.account_meta_key]` when an
|
|
7
|
+
# `account_id` is supplied. The caller passes the already-resolved account id (a
|
|
8
|
+
# scalar), not a domain object — resolving the tenant is the consumer's job.
|
|
9
|
+
#
|
|
10
|
+
# Error mapping is deliberately thin and transport-agnostic:
|
|
11
|
+
# * an unregistered `app_key` raises McpToolkit::Gateway::UnknownUpstream;
|
|
12
|
+
# * an upstream call failure is re-raised as McpToolkit::Gateway::UpstreamCallError
|
|
13
|
+
# carrying the upstream's `jsonrpc_error` / `http_status`.
|
|
14
|
+
# Neither is mapped to a JSON-RPC/protocol error class here — the consuming
|
|
15
|
+
# dispatcher does that at its call site.
|
|
16
|
+
class McpToolkit::Gateway::Proxy
|
|
17
|
+
attr_reader :app_key, :tool_name, :account_id, :bearer_token, :config
|
|
18
|
+
|
|
19
|
+
def initialize(app_key:, tool_name:, account_id: nil, bearer_token: nil, config: McpToolkit.config)
|
|
20
|
+
@app_key = app_key
|
|
21
|
+
@tool_name = tool_name
|
|
22
|
+
@account_id = account_id
|
|
23
|
+
@bearer_token = bearer_token
|
|
24
|
+
@config = config
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call(arguments)
|
|
28
|
+
upstream = config.upstreams.find(app_key)
|
|
29
|
+
raise McpToolkit::Gateway::UnknownUpstream, "Unknown application: #{app_key}" if upstream.nil?
|
|
30
|
+
|
|
31
|
+
client = McpToolkit::Gateway::Client.new(upstream:, bearer_token:, config:)
|
|
32
|
+
client.tools_call(name: tool_name, arguments:, meta:)
|
|
33
|
+
rescue McpToolkit::Gateway::Client::Error => e
|
|
34
|
+
relay_upstream_error(e)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def relay_upstream_error(error)
|
|
40
|
+
log_proxied_failure(error)
|
|
41
|
+
|
|
42
|
+
raise McpToolkit::Gateway::UpstreamCallError.new(
|
|
43
|
+
error.message,
|
|
44
|
+
jsonrpc_error: error.jsonrpc_error,
|
|
45
|
+
http_status: error.http_status
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Emit one concise, greppable ERROR line per failed proxied call — never a
|
|
50
|
+
# bearer token or a full response body.
|
|
51
|
+
def log_proxied_failure(error)
|
|
52
|
+
config.logger&.error(
|
|
53
|
+
"MCP upstream #{app_key} tools/call #{tool_name} failed#{failure_detail(error)}: #{error.message}"
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def failure_detail(error)
|
|
58
|
+
code = error.jsonrpc_error&.dig("code")
|
|
59
|
+
return " (jsonrpc_code=#{code})" if code
|
|
60
|
+
return " (http_status=#{error.http_status})" if error.http_status
|
|
61
|
+
|
|
62
|
+
""
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def meta
|
|
66
|
+
return nil if account_id.nil?
|
|
67
|
+
|
|
68
|
+
{ config.account_meta_key => account_id }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Raised by McpToolkit::Gateway::Proxy when a namespaced tool call targets an
|
|
4
|
+
# upstream key that is not registered. The GEM does NOT translate this into any
|
|
5
|
+
# JSON-RPC / protocol error class — the consuming dispatcher maps it to whatever
|
|
6
|
+
# error shape its transport speaks (e.g. a "method not found"). Kept a plain
|
|
7
|
+
# McpToolkit::Error so the gem stays transport-agnostic.
|
|
8
|
+
class McpToolkit::Gateway::UnknownUpstream < McpToolkit::Error; end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Raised by McpToolkit::Gateway::Proxy when a proxied `tools/call` fails at the
|
|
4
|
+
# upstream. It carries the upstream failure detail so the CONSUMER can decide how
|
|
5
|
+
# to surface it:
|
|
6
|
+
#
|
|
7
|
+
# * `jsonrpc_error` — the upstream's JSON-RPC error hash when the failure was a
|
|
8
|
+
# protocol-level error response (so the consumer can relay it verbatim); nil
|
|
9
|
+
# for transport/HTTP failures.
|
|
10
|
+
# * `http_status` — the HTTP status for a non-2xx response (nil otherwise).
|
|
11
|
+
#
|
|
12
|
+
# The gem deliberately does NOT map this to a protocol/transport error class:
|
|
13
|
+
# that mapping lives in the consuming dispatcher, keeping the gateway
|
|
14
|
+
# transport-agnostic.
|
|
15
|
+
class McpToolkit::Gateway::UpstreamCallError < McpToolkit::Error
|
|
16
|
+
attr_reader :jsonrpc_error, :http_status
|
|
17
|
+
|
|
18
|
+
def initialize(message, jsonrpc_error: nil, http_status: nil)
|
|
19
|
+
super(message)
|
|
20
|
+
@jsonrpc_error = jsonrpc_error
|
|
21
|
+
@http_status = http_status
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Registry of upstream MCP servers a GATEWAY app aggregates and proxies to.
|
|
4
|
+
#
|
|
5
|
+
# Each upstream has a `key` (the tool-name namespace prefix — its tools are
|
|
6
|
+
# surfaced as `<key>__<tool>`) and a `url` (the upstream's MCP HTTP endpoint).
|
|
7
|
+
# Upstreams are registered at boot (typically from ENV); an upstream whose url is
|
|
8
|
+
# blank is never registered, so an unconfigured environment behaves exactly like
|
|
9
|
+
# a gateway with no upstreams.
|
|
10
|
+
#
|
|
11
|
+
# Unlike a global module singleton, this is a PER-CONFIG instance (like
|
|
12
|
+
# `config.registry`): each `McpToolkit::Configuration` carries its own, exposed as
|
|
13
|
+
# `config.upstreams`. That gives test isolation for free (a fresh config resets
|
|
14
|
+
# it) and matches the gem's per-config convention. Register via the config sugar
|
|
15
|
+
# `config.register_upstream(key:, url:)` or directly on `config.upstreams`.
|
|
16
|
+
class McpToolkit::Gateway::UpstreamRegistry
|
|
17
|
+
# Separates an app key from a tool name in an aggregated tool name. A double
|
|
18
|
+
# underscore so it doesn't collide with single underscores in bare tool names
|
|
19
|
+
# (e.g. "list_items"); it also matches the gem's `<app>__<action>` scope
|
|
20
|
+
# separator.
|
|
21
|
+
NAMESPACE_SEPARATOR = "__"
|
|
22
|
+
|
|
23
|
+
# `public_tool_list` is the gateway-cache contract: an upstream's `tools/list`
|
|
24
|
+
# MUST be caller-INDEPENDENT (the same public tools for every valid token; scope
|
|
25
|
+
# is enforced only when a tool is CALLED). That invariant is what lets the
|
|
26
|
+
# Aggregator share one cache entry per upstream across all callers. An upstream
|
|
27
|
+
# that filters its list by the caller's privilege (e.g. hides superuser-only
|
|
28
|
+
# tools) breaks it and MUST register `public_tool_list: false`, which opts it out
|
|
29
|
+
# of the shared cache so a privileged caller's list can't leak to an
|
|
30
|
+
# unprivileged one. Default true — the overwhelming common case.
|
|
31
|
+
Upstream = Data.define(:key, :url, :public_tool_list) do
|
|
32
|
+
def initialize(key:, url:, public_tool_list: true)
|
|
33
|
+
super
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def name_for(tool_name)
|
|
37
|
+
"#{key}#{NAMESPACE_SEPARATOR}#{tool_name}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize
|
|
42
|
+
@registered = {}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Registers an upstream by key. A blank url is ignored, so callers can pass an
|
|
46
|
+
# ENV lookup directly without guarding it. Pass `public_tool_list: false` for an
|
|
47
|
+
# upstream whose tool list varies by caller privilege, opting it out of the
|
|
48
|
+
# shared list cache (see the Upstream contract above).
|
|
49
|
+
def register(key:, url:, public_tool_list: true)
|
|
50
|
+
return if url.blank?
|
|
51
|
+
|
|
52
|
+
@registered[key.to_s] = Upstream.new(key: key.to_s, url:, public_tool_list:)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Clears every registered upstream.
|
|
56
|
+
def reset!
|
|
57
|
+
@registered = {}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# All registered upstreams (insertion order).
|
|
61
|
+
def all
|
|
62
|
+
@registered.values
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The registered upstream for a key, or nil.
|
|
66
|
+
def find(key)
|
|
67
|
+
@registered[key.to_s]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# `<app>__<tool>` -> [app_key, bare_tool_name] for a registered upstream; nil
|
|
71
|
+
# for an un-namespaced name or an unknown/unregistered key.
|
|
72
|
+
def split_tool_name(tool_name)
|
|
73
|
+
prefix, separator, rest = tool_name.to_s.partition(NAMESPACE_SEPARATOR)
|
|
74
|
+
return nil if separator.empty? || rest.empty?
|
|
75
|
+
return nil unless find(prefix)
|
|
76
|
+
|
|
77
|
+
[prefix, rest]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -45,10 +45,27 @@ class McpToolkit::ListExecutor
|
|
|
45
45
|
relation = resource.resolve_relation(scope_root)
|
|
46
46
|
relation = apply_ids(relation)
|
|
47
47
|
relation = apply_updated_since(relation)
|
|
48
|
+
relation = apply_custom_filters(relation)
|
|
48
49
|
relation = apply_attribute_filters(relation)
|
|
49
50
|
apply_order(relation)
|
|
50
51
|
end
|
|
51
52
|
|
|
53
|
+
# Applies the resource's declared custom (resource-specific) filters — each an
|
|
54
|
+
# arbitrary host-supplied block — for the keys actually present as TOP-LEVEL
|
|
55
|
+
# request params, BEFORE the generic allowlist `filter` attributes. Each block
|
|
56
|
+
# receives the already-scoped relation and the raw value and returns a narrowed
|
|
57
|
+
# relation, so a host can express a relational filter the generic path can't
|
|
58
|
+
# derive (see Resource#filter). A resource with no custom filters is a no-op.
|
|
59
|
+
def apply_custom_filters(relation)
|
|
60
|
+
resource.custom_filters.each_value do |custom_filter|
|
|
61
|
+
value = params[custom_filter.name]
|
|
62
|
+
next if value.nil? || value == ""
|
|
63
|
+
|
|
64
|
+
relation = custom_filter.applier.call(relation, value)
|
|
65
|
+
end
|
|
66
|
+
relation
|
|
67
|
+
end
|
|
68
|
+
|
|
52
69
|
# Order by `id` when the primary key is numeric; otherwise by `created_at`
|
|
53
70
|
# (a non-numeric PK does not sort meaningfully).
|
|
54
71
|
def apply_order(relation)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# MCP JSON-RPC protocol constants + helpers for the hand-rolled AUTHORITY
|
|
4
|
+
# dispatcher (McpToolkit::Dispatcher). Based on the Model Context Protocol
|
|
5
|
+
# specification.
|
|
6
|
+
#
|
|
7
|
+
# This is the wire vocabulary of a first-party MCP endpoint that serves its own
|
|
8
|
+
# tools (and, as a gateway, aggregates upstream ones) WITHOUT the official `mcp`
|
|
9
|
+
# SDK in the request path. The SDK-backed satellite path
|
|
10
|
+
# (McpToolkit::Server.build) does not use this module; the two dispatch
|
|
11
|
+
# front-ends coexist by design.
|
|
12
|
+
#
|
|
13
|
+
# The error codes, the success/error response envelopes, and the
|
|
14
|
+
# version-negotiation constants here are the BYTE contract a monetized authority
|
|
15
|
+
# endpoint depends on, so they are kept fixed. `SUPPORTED_VERSIONS` is the module
|
|
16
|
+
# default; a host negotiates against `config.supported_protocol_versions`
|
|
17
|
+
# (defaulting to the same list) so the set is overridable without editing this
|
|
18
|
+
# file.
|
|
19
|
+
module McpToolkit::Protocol
|
|
20
|
+
# Protocol versions the server supports, newest first. The version returned in
|
|
21
|
+
# the `initialize` response is the requested version (if supported) or the
|
|
22
|
+
# latest the server supports, per the MCP spec's version-negotiation rules.
|
|
23
|
+
SUPPORTED_VERSIONS = %w[2025-06-18 2025-03-26 2024-11-05].freeze
|
|
24
|
+
LATEST_VERSION = SUPPORTED_VERSIONS.first
|
|
25
|
+
# Kept for backwards compatibility; prefer LATEST_VERSION going forward.
|
|
26
|
+
VERSION = LATEST_VERSION
|
|
27
|
+
|
|
28
|
+
JSONRPC_VERSION = "2.0"
|
|
29
|
+
|
|
30
|
+
# Error codes per JSON-RPC 2.0 spec.
|
|
31
|
+
module ErrorCodes
|
|
32
|
+
PARSE_ERROR = -32_700
|
|
33
|
+
INVALID_REQUEST = -32_600
|
|
34
|
+
METHOD_NOT_FOUND = -32_601
|
|
35
|
+
INVALID_PARAMS = -32_602
|
|
36
|
+
INTERNAL_ERROR = -32_603
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Base protocol error. `code`/`data` land verbatim in the JSON-RPC `error`
|
|
40
|
+
# object via `#to_h`; the dispatcher turns a raised Error into a top-level
|
|
41
|
+
# JSON-RPC error response (the envelope a client sees for a bad tool arg or an
|
|
42
|
+
# unknown method).
|
|
43
|
+
class Error < StandardError
|
|
44
|
+
attr_reader :code, :data
|
|
45
|
+
|
|
46
|
+
def initialize(message, code:, data: nil)
|
|
47
|
+
super(message)
|
|
48
|
+
@code = code
|
|
49
|
+
@data = data
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def to_h
|
|
53
|
+
error = { code:, message: }
|
|
54
|
+
error[:data] = data if data
|
|
55
|
+
error
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class ParseError < Error
|
|
60
|
+
def initialize(message = "Parse error", data: nil)
|
|
61
|
+
super(message, code: ErrorCodes::PARSE_ERROR, data:)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class InvalidRequest < Error
|
|
66
|
+
def initialize(message = "Invalid request", data: nil)
|
|
67
|
+
super(message, code: ErrorCodes::INVALID_REQUEST, data:)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
class MethodNotFound < Error
|
|
72
|
+
def initialize(method_name, data: nil)
|
|
73
|
+
super("Method not found: #{method_name}", code: ErrorCodes::METHOD_NOT_FOUND, data:)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
class InvalidParams < Error
|
|
78
|
+
def initialize(message = "Invalid params", data: nil)
|
|
79
|
+
super(message, code: ErrorCodes::INVALID_PARAMS, data:)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
class InternalError < Error
|
|
84
|
+
def initialize(message = "Internal error", data: nil)
|
|
85
|
+
super(message, code: ErrorCodes::INTERNAL_ERROR, data:)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
module_function
|
|
90
|
+
|
|
91
|
+
def success_response(id:, result:)
|
|
92
|
+
{
|
|
93
|
+
jsonrpc: JSONRPC_VERSION,
|
|
94
|
+
id:,
|
|
95
|
+
result:
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def error_response(id:, error:)
|
|
100
|
+
{
|
|
101
|
+
jsonrpc: JSONRPC_VERSION,
|
|
102
|
+
id:,
|
|
103
|
+
error: error.is_a?(Error) ? error.to_h : error
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
end
|