mcp_toolkit 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +201 -2
  3. data/README.md +315 -34
  4. data/config/routes.rb +19 -4
  5. data/lib/mcp_toolkit/auth/authority.rb +2 -2
  6. data/lib/mcp_toolkit/authority/composite_tool_provider.rb +32 -0
  7. data/lib/mcp_toolkit/authority/context.rb +45 -0
  8. data/lib/mcp_toolkit/authority/controller_methods.rb +408 -0
  9. data/lib/mcp_toolkit/authority/registry_tool_provider.rb +70 -0
  10. data/lib/mcp_toolkit/authority/token.rb +124 -0
  11. data/lib/mcp_toolkit/authority/tools/base.rb +122 -0
  12. data/lib/mcp_toolkit/authority/tools/get.rb +58 -0
  13. data/lib/mcp_toolkit/authority/tools/list.rb +84 -0
  14. data/lib/mcp_toolkit/authority/tools/resource_schema.rb +44 -0
  15. data/lib/mcp_toolkit/authority/tools/resources.rb +33 -0
  16. data/lib/mcp_toolkit/authority.rb +24 -0
  17. data/lib/mcp_toolkit/configuration.rb +205 -2
  18. data/lib/mcp_toolkit/dispatcher.rb +234 -0
  19. data/lib/mcp_toolkit/engine.rb +22 -7
  20. data/lib/mcp_toolkit/engine_controllers.rb +116 -0
  21. data/lib/mcp_toolkit/gateway/aggregator.rb +122 -0
  22. data/lib/mcp_toolkit/gateway/client.rb +305 -0
  23. data/lib/mcp_toolkit/gateway/proxy.rb +70 -0
  24. data/lib/mcp_toolkit/gateway/unknown_upstream.rb +8 -0
  25. data/lib/mcp_toolkit/gateway/upstream_call_error.rb +23 -0
  26. data/lib/mcp_toolkit/gateway/upstream_registry.rb +79 -0
  27. data/lib/mcp_toolkit/list_executor.rb +17 -0
  28. data/lib/mcp_toolkit/protocol.rb +106 -0
  29. data/lib/mcp_toolkit/rate_limiter.rb +73 -0
  30. data/lib/mcp_toolkit/registry.rb +30 -2
  31. data/lib/mcp_toolkit/resource.rb +125 -7
  32. data/lib/mcp_toolkit/resource_schema.rb +14 -1
  33. data/lib/mcp_toolkit/serializer/base.rb +2 -2
  34. data/lib/mcp_toolkit/session.rb +17 -9
  35. data/lib/mcp_toolkit/tools/authority_base.rb +127 -0
  36. data/lib/mcp_toolkit/transport/controller_methods.rb +2 -2
  37. data/lib/mcp_toolkit/usage_metering/recorder.rb +95 -0
  38. data/lib/mcp_toolkit/version.rb +1 -1
  39. data/lib/mcp_toolkit.rb +16 -3
  40. metadata +38 -2
  41. data/app/controllers/mcp_toolkit/server_controller.rb +0 -19
@@ -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