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,408 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The AUTHORITY-side MCP Streamable-HTTP transport, provided as an includable
|
|
4
|
+
# concern. Unlike the satellite transport (McpToolkit::Transport::ControllerMethods,
|
|
5
|
+
# which forwards a token to a central app for per-tool introspection), the
|
|
6
|
+
# authority AUTHENTICATES the token locally and dispatches through the hand-rolled
|
|
7
|
+
# McpToolkit::Dispatcher, serving its own tools and (as a gateway) proxying
|
|
8
|
+
# upstreams.
|
|
9
|
+
#
|
|
10
|
+
# Because the POST endpoint is the billing/tenancy boundary of a first-party
|
|
11
|
+
# server, EVERY billing/tenancy step is an overridable hook. A pure host can drive
|
|
12
|
+
# the whole thing from config callables (`rate_limiter`, `usage_recorder`,
|
|
13
|
+
# `usage_flusher`, `tool_provider`, `token_authenticator`); a host whose metering
|
|
14
|
+
# touches its own models subclasses McpToolkit::Authority::ServerController and
|
|
15
|
+
# overrides the hook methods directly (the recommended path).
|
|
16
|
+
#
|
|
17
|
+
# Endpoints
|
|
18
|
+
# POST /mcp - JSON-RPC requests/responses (single or batch)
|
|
19
|
+
# GET /mcp - server-initiated SSE stream (none emitted; 405)
|
|
20
|
+
# DELETE /mcp - terminate the current session
|
|
21
|
+
# GET /mcp/health - unauthenticated health probe
|
|
22
|
+
#
|
|
23
|
+
# Per-request loop (the metering-critical invariant)
|
|
24
|
+
# Each JSON-RPC call — including every element of a batch — RE-RESOLVES its
|
|
25
|
+
# account from its own `_meta` / `account_id` argument, then tracks usage, then
|
|
26
|
+
# dispatches with a fresh Authority::Context. The batch is deliberately NOT
|
|
27
|
+
# delegated to a bulk handler that can't re-resolve per element, so a mixed-
|
|
28
|
+
# account batch still meters one usage event per call against the right account.
|
|
29
|
+
#
|
|
30
|
+
# Overridable hooks (defaults in parentheses)
|
|
31
|
+
# mcp_config -> the McpToolkit::Configuration (McpToolkit.config)
|
|
32
|
+
# mcp_authenticate! -> set @mcp_principal or render 401 (local token auth via
|
|
33
|
+
# config.token_authenticator, through Auth::Authority)
|
|
34
|
+
# mcp_rate_limit! -> throttle (built-in McpToolkit::RateLimiter when
|
|
35
|
+
# config.rate_limit_max_requests is set; config.rate_limiter
|
|
36
|
+
# escape hatch takes precedence; no-op when neither)
|
|
37
|
+
# mcp_track_usage -> record one usage event (config.usage_recorder&.call)
|
|
38
|
+
# mcp_flush_usage -> persist accumulated usage (config.usage_flusher&.call)
|
|
39
|
+
# mcp_resolve_account -> the account for one call (principal#default_account /
|
|
40
|
+
# principal#authorize_account(id))
|
|
41
|
+
# mcp_session_data -> opaque payload bound to the session (config.session_data_builder,
|
|
42
|
+
# else {}; a host binds e.g. { token_id: principal.id } so a
|
|
43
|
+
# revoked token kills the session)
|
|
44
|
+
# mcp_dispatch -> run one JSON-RPC call (Dispatcher + Authority::Context)
|
|
45
|
+
# mcp_health_payload -> the GET /mcp/health body
|
|
46
|
+
#
|
|
47
|
+
# CSRF: the concern disables forgery protection (this is a token-authenticated
|
|
48
|
+
# JSON API). Its host controller should inherit from ActionController::API (or
|
|
49
|
+
# ::Base with null_session) via `config.parent_controller`.
|
|
50
|
+
module McpToolkit::Authority::ControllerMethods
|
|
51
|
+
extend ActiveSupport::Concern
|
|
52
|
+
|
|
53
|
+
SESSION_HEADER = "Mcp-Session-Id"
|
|
54
|
+
|
|
55
|
+
included do
|
|
56
|
+
protect_from_forgery with: :null_session if respond_to?(:protect_from_forgery)
|
|
57
|
+
|
|
58
|
+
# A Protocol::Error that escapes the per-call loop is, in practice, a
|
|
59
|
+
# parse/shape error raised at the request boundary (a malformed JSON body from
|
|
60
|
+
# mcp_parse_body, fired here even from the mcp_resolve_session! before_action).
|
|
61
|
+
# Without this it would surface as a framework 500; the MCP spec wants a
|
|
62
|
+
# JSON-RPC error envelope for bad input. Guarded behind respond_to? like
|
|
63
|
+
# protect_from_forgery: every real host (ActionController::API/::Base) carries
|
|
64
|
+
# ActiveSupport::Rescuable, a bare non-Rails host simply skips it.
|
|
65
|
+
rescue_from McpToolkit::Protocol::Error, with: :mcp_render_protocol_error if respond_to?(:rescue_from)
|
|
66
|
+
|
|
67
|
+
before_action :mcp_authenticate!, except: [:health]
|
|
68
|
+
before_action :mcp_rate_limit!, except: [:health]
|
|
69
|
+
before_action :mcp_resolve_session!, only: [:create]
|
|
70
|
+
before_action :mcp_require_session!, only: [:destroy]
|
|
71
|
+
after_action :mcp_flush_usage
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def create
|
|
75
|
+
request_body = mcp_parse_body
|
|
76
|
+
|
|
77
|
+
if request_body.is_a?(Array)
|
|
78
|
+
mcp_handle_batch(request_body)
|
|
79
|
+
else
|
|
80
|
+
mcp_handle_single(request_body)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# GET /mcp — opens an SSE stream for server-initiated messages. None are emitted
|
|
85
|
+
# (no sampling, progress, or notifications today), so we reply 405 as the MCP
|
|
86
|
+
# spec explicitly allows.
|
|
87
|
+
def stream
|
|
88
|
+
head :method_not_allowed
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# DELETE /mcp — terminate the current session.
|
|
92
|
+
def destroy
|
|
93
|
+
McpToolkit::Session.delete(request.headers[SESSION_HEADER], config: mcp_config)
|
|
94
|
+
head :no_content
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# GET /mcp/health — unauthenticated health probe.
|
|
98
|
+
def health
|
|
99
|
+
render json: mcp_health_payload
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# ---- overridable hooks ----------------------------------------------
|
|
105
|
+
|
|
106
|
+
def mcp_config
|
|
107
|
+
McpToolkit.config
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The authenticated principal for this request (the token object), set by
|
|
111
|
+
# mcp_authenticate!. Read by the other hooks.
|
|
112
|
+
def mcp_principal
|
|
113
|
+
@mcp_principal
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Authenticate the bearer LOCALLY (the authority's job). The default resolves it
|
|
117
|
+
# through Auth::Authority (config.token_authenticator), which also touches
|
|
118
|
+
# last-used. Renders a JSON-RPC 401 and halts on a missing/invalid token.
|
|
119
|
+
def mcp_authenticate!
|
|
120
|
+
token = mcp_extract_token
|
|
121
|
+
return mcp_render_unauthorized("Missing authorization token") if token.blank?
|
|
122
|
+
|
|
123
|
+
@mcp_principal = McpToolkit::Auth::Authority.authenticate(token, config: mcp_config)
|
|
124
|
+
mcp_render_unauthorized("Invalid or expired token") unless @mcp_principal
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Throttle the request. Precedence:
|
|
128
|
+
# 1. `config.rate_limiter` escape hatch, if set (host-owned counting);
|
|
129
|
+
# 2. otherwise the built-in McpToolkit::RateLimiter when a cap is configured
|
|
130
|
+
# (via the `mcp_rate_limit_max_requests` hook, default
|
|
131
|
+
# `config.rate_limit_max_requests`);
|
|
132
|
+
# 3. otherwise a no-op (no cap => pure host unaffected).
|
|
133
|
+
# On every capped request it sets the X-RateLimit-* headers; over the limit it
|
|
134
|
+
# additionally sets Retry-After and renders the JSON-RPC error + 429 (halting
|
|
135
|
+
# the filter chain).
|
|
136
|
+
def mcp_rate_limit!
|
|
137
|
+
return mcp_config.rate_limiter.call(controller: self, principal: mcp_principal) if mcp_config.rate_limiter
|
|
138
|
+
|
|
139
|
+
max = mcp_rate_limit_max_requests
|
|
140
|
+
return if max.nil?
|
|
141
|
+
|
|
142
|
+
result = McpToolkit::RateLimiter.new(
|
|
143
|
+
key: mcp_rate_limit_key,
|
|
144
|
+
max_requests: max,
|
|
145
|
+
window: mcp_config.rate_limit_window,
|
|
146
|
+
cache_store: mcp_config.cache_store
|
|
147
|
+
).call
|
|
148
|
+
|
|
149
|
+
mcp_set_rate_limit_headers(result)
|
|
150
|
+
mcp_render_rate_limited(result) unless result.allowed?
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The per-window request cap the built-in limiter enforces, or nil to disable
|
|
154
|
+
# it. Default: `config.rate_limit_max_requests`. A host that keeps its cap in a
|
|
155
|
+
# constant/model overrides this (e.g. `= MyController::RATE_LIMIT`).
|
|
156
|
+
def mcp_rate_limit_max_requests
|
|
157
|
+
mcp_config.rate_limit_max_requests
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The identity the built-in limiter counts against. Default: the principal id.
|
|
161
|
+
# Override to bucket differently (e.g. per account, or a composite key).
|
|
162
|
+
def mcp_rate_limit_key
|
|
163
|
+
mcp_principal.id
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Sets the X-RateLimit-* headers from a RateLimiter result (on every capped
|
|
167
|
+
# response, allowed or not).
|
|
168
|
+
def mcp_set_rate_limit_headers(result)
|
|
169
|
+
response.headers["X-RateLimit-Limit"] = result.limit.to_s
|
|
170
|
+
response.headers["X-RateLimit-Reset"] = result.reset_at.to_s
|
|
171
|
+
response.headers["X-RateLimit-Remaining"] = result.remaining.to_s
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Renders the over-limit response: the Retry-After header plus a JSON-RPC error
|
|
175
|
+
# envelope (code -32029) at HTTP 429. Called as a before_action, so the render
|
|
176
|
+
# halts the request.
|
|
177
|
+
def mcp_render_rate_limited(result)
|
|
178
|
+
response.headers["Retry-After"] = result.retry_after.to_s
|
|
179
|
+
render json: {
|
|
180
|
+
jsonrpc: McpToolkit::Protocol::JSONRPC_VERSION,
|
|
181
|
+
id: nil,
|
|
182
|
+
error: {
|
|
183
|
+
code: -32_029,
|
|
184
|
+
message: "Rate limit exceeded. Retry after #{result.retry_after}s."
|
|
185
|
+
}
|
|
186
|
+
}, status: :too_many_requests
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Record one usage event for a single JSON-RPC call. The default delegates to
|
|
190
|
+
# `config.usage_recorder`; a host that accumulates into its own ledger overrides
|
|
191
|
+
# this. MUST never affect the MCP response.
|
|
192
|
+
def mcp_track_usage(request_data, account)
|
|
193
|
+
mcp_config.usage_recorder&.call(
|
|
194
|
+
request_data:, account:, principal: mcp_principal, controller: self
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Persist accumulated usage (after_action). The default delegates to
|
|
199
|
+
# `config.usage_flusher`. MUST never affect the MCP response.
|
|
200
|
+
def mcp_flush_usage
|
|
201
|
+
mcp_config.usage_flusher&.call(controller: self)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Pick the active account for a SINGLE JSON-RPC call. Duck-typed on the
|
|
205
|
+
# principal: no candidate -> its default account (nil for a multi-account
|
|
206
|
+
# token); a candidate -> the authorized account or a JSON-RPC InvalidParams.
|
|
207
|
+
def mcp_resolve_account(request_data)
|
|
208
|
+
candidate = mcp_candidate_account_id(request_data)
|
|
209
|
+
return mcp_principal.default_account if candidate.blank?
|
|
210
|
+
|
|
211
|
+
account = mcp_principal.authorize_account(candidate)
|
|
212
|
+
return account if account
|
|
213
|
+
|
|
214
|
+
raise McpToolkit::Protocol::InvalidParams, "account_id #{candidate.inspect} is not authorized for this token"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Opaque payload bound to the session on `initialize`. Default: driven by
|
|
218
|
+
# `config.session_data_builder` (a host binds e.g. `{ token_id: principal.id }`
|
|
219
|
+
# so a revoked token can kill an in-flight session), or an empty payload when no
|
|
220
|
+
# builder is set. A host whose session payload needs controller state overrides
|
|
221
|
+
# this method instead.
|
|
222
|
+
def mcp_session_data
|
|
223
|
+
builder = mcp_config.session_data_builder
|
|
224
|
+
return {} if builder.nil?
|
|
225
|
+
|
|
226
|
+
builder.call(principal: mcp_principal) || {}
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Run one JSON-RPC call through the hand-rolled dispatcher with a fresh
|
|
230
|
+
# per-request context.
|
|
231
|
+
def mcp_dispatch(request_data, account)
|
|
232
|
+
context = McpToolkit::Authority::Context.new(
|
|
233
|
+
account:, principal: mcp_principal, bearer_token: mcp_extract_token
|
|
234
|
+
)
|
|
235
|
+
McpToolkit::Dispatcher.new(context:, config: mcp_config).handle_request(request_data)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def mcp_health_payload
|
|
239
|
+
{
|
|
240
|
+
status: "ok",
|
|
241
|
+
server: mcp_config.server_name,
|
|
242
|
+
version: mcp_config.server_version,
|
|
243
|
+
protocol_version: McpToolkit::Protocol::LATEST_VERSION
|
|
244
|
+
}
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# ---- request handling -----------------------------------------------
|
|
248
|
+
|
|
249
|
+
def mcp_handle_batch(requests)
|
|
250
|
+
responses = requests.filter_map { |req| mcp_process_single_request(req) }
|
|
251
|
+
return head :accepted if responses.empty?
|
|
252
|
+
|
|
253
|
+
mcp_render_response(responses)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def mcp_handle_single(request_data)
|
|
257
|
+
response_data = mcp_process_single_request(request_data)
|
|
258
|
+
return head :accepted if response_data.nil?
|
|
259
|
+
|
|
260
|
+
mcp_render_response(response_data)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# The per-request loop body: resolve THIS call's account, meter it, dispatch.
|
|
264
|
+
# A protocol error raised while resolving the account (e.g. an unauthorized
|
|
265
|
+
# account id) becomes this call's JSON-RPC error, leaving sibling batch elements
|
|
266
|
+
# untouched.
|
|
267
|
+
def mcp_process_single_request(request_data)
|
|
268
|
+
# A request element that isn't a JSON object can't carry an id, a method, or
|
|
269
|
+
# params, so downstream `.to_h`/`.key?` would raise a NoMethodError (a 500).
|
|
270
|
+
# Treat it as a JSON-RPC InvalidRequest with a null id instead.
|
|
271
|
+
return mcp_invalid_request_response unless request_data.is_a?(Hash)
|
|
272
|
+
|
|
273
|
+
account = mcp_resolve_account(request_data)
|
|
274
|
+
mcp_track_usage(request_data, account)
|
|
275
|
+
mcp_dispatch(request_data, account)
|
|
276
|
+
rescue McpToolkit::Protocol::Error => e
|
|
277
|
+
return nil unless request_data.is_a?(Hash) && request_data.key?("id")
|
|
278
|
+
|
|
279
|
+
McpToolkit::Protocol.error_response(id: request_data["id"], error: e)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# The response for a non-object request element: no id/method is recoverable, so
|
|
283
|
+
# it's an InvalidRequest carrying a null id (JSON-RPC 2.0 §4.2 / batch handling).
|
|
284
|
+
def mcp_invalid_request_response
|
|
285
|
+
McpToolkit::Protocol.error_response(
|
|
286
|
+
id: nil,
|
|
287
|
+
error: McpToolkit::Protocol::InvalidRequest.new("Request must be a JSON object")
|
|
288
|
+
)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Renders the JSON-RPC payload as application/json (default) or text/event-stream
|
|
292
|
+
# when the client's Accept header includes "text/event-stream". We never actually
|
|
293
|
+
# stream — one message then EOF — but emitting SSE on demand keeps strict MCP
|
|
294
|
+
# clients happy.
|
|
295
|
+
def mcp_render_response(payload)
|
|
296
|
+
if mcp_event_stream_requested?
|
|
297
|
+
mcp_render_sse_stream(payload)
|
|
298
|
+
else
|
|
299
|
+
render json: payload
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def mcp_render_sse_stream(payload)
|
|
304
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
305
|
+
messages = payload.is_a?(Array) ? payload : [payload]
|
|
306
|
+
body = messages.map { |message| mcp_format_sse_event(message) }.join
|
|
307
|
+
|
|
308
|
+
render body:, content_type: Mime::Type.lookup("text/event-stream").to_s
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def mcp_format_sse_event(message)
|
|
312
|
+
"event: message\ndata: #{message.to_json}\n\n"
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def mcp_event_stream_requested?
|
|
316
|
+
request.headers["Accept"].to_s.include?("text/event-stream")
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# ---- session lifecycle ----------------------------------------------
|
|
320
|
+
|
|
321
|
+
# POST: create a session on `initialize` (binding mcp_session_data), otherwise
|
|
322
|
+
# require an existing one.
|
|
323
|
+
def mcp_resolve_session!
|
|
324
|
+
methods = mcp_methods_from(mcp_parse_body)
|
|
325
|
+
|
|
326
|
+
if methods.include?("initialize")
|
|
327
|
+
@mcp_session = McpToolkit::Session.create!(data: mcp_session_data, config: mcp_config)
|
|
328
|
+
else
|
|
329
|
+
@mcp_session = McpToolkit::Session.find(request.headers[SESSION_HEADER], config: mcp_config)
|
|
330
|
+
return mcp_render_session_not_found if @mcp_session.nil?
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
response.headers[SESSION_HEADER] = @mcp_session.id
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def mcp_require_session!
|
|
337
|
+
@mcp_session = McpToolkit::Session.find(request.headers[SESSION_HEADER], config: mcp_config)
|
|
338
|
+
mcp_render_session_not_found if @mcp_session.nil?
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# ---- token + account extraction -------------------------------------
|
|
342
|
+
|
|
343
|
+
# Token sources, highest priority first: Bearer header, legacy X-MCP-Token
|
|
344
|
+
# header, then the `token` query param (the header-less client fallback).
|
|
345
|
+
def mcp_extract_token
|
|
346
|
+
auth_header = request.headers["Authorization"]
|
|
347
|
+
return auth_header.sub("Bearer ", "") if auth_header&.start_with?("Bearer ")
|
|
348
|
+
|
|
349
|
+
request.headers["X-MCP-Token"].presence || params[:token].presence
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Account selector for a single call, highest priority first: params._meta key,
|
|
353
|
+
# tools/call `account_id` argument, then the account-id header (request-wide
|
|
354
|
+
# fallback). Key/header names come from config so a host on a specific authority
|
|
355
|
+
# can match that authority's convention.
|
|
356
|
+
def mcp_candidate_account_id(request_data)
|
|
357
|
+
params = request_data.to_h["params"].to_h
|
|
358
|
+
meta = params["_meta"].to_h
|
|
359
|
+
arguments = params["arguments"].to_h
|
|
360
|
+
|
|
361
|
+
meta[mcp_config.account_meta_key] ||
|
|
362
|
+
arguments["account_id"] ||
|
|
363
|
+
request.headers[mcp_config.account_id_header]
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# ---- error renders --------------------------------------------------
|
|
367
|
+
|
|
368
|
+
def mcp_render_unauthorized(message)
|
|
369
|
+
render json: {
|
|
370
|
+
jsonrpc: McpToolkit::Protocol::JSONRPC_VERSION,
|
|
371
|
+
id: nil,
|
|
372
|
+
error: {
|
|
373
|
+
code: -32_000,
|
|
374
|
+
message: "Unauthorized: #{message}"
|
|
375
|
+
}
|
|
376
|
+
}, status: :unauthorized
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def mcp_render_session_not_found
|
|
380
|
+
render json: {
|
|
381
|
+
jsonrpc: McpToolkit::Protocol::JSONRPC_VERSION,
|
|
382
|
+
id: nil,
|
|
383
|
+
error: { code: -32_001, message: "Session not found or expired" }
|
|
384
|
+
}, status: :not_found
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Renders a boundary Protocol::Error (parse/shape failure) as a JSON-RPC error
|
|
388
|
+
# envelope with a null id at HTTP 400, rather than letting it become a 500.
|
|
389
|
+
def mcp_render_protocol_error(error)
|
|
390
|
+
render json: McpToolkit::Protocol.error_response(id: nil, error:), status: :bad_request
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# ---- body parsing ---------------------------------------------------
|
|
394
|
+
|
|
395
|
+
def mcp_parse_body
|
|
396
|
+
return @mcp_parsed_body if defined?(@mcp_parsed_body)
|
|
397
|
+
|
|
398
|
+
@mcp_parsed_body = JSON.parse(request.body.read)
|
|
399
|
+
rescue JSON::ParserError => e
|
|
400
|
+
raise McpToolkit::Protocol::ParseError, "Invalid JSON: #{e.message}"
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def mcp_methods_from(request_body)
|
|
404
|
+
# Array.wrap (not Kernel#Array): a single JSON-RPC Hash must wrap to [hash],
|
|
405
|
+
# whereas Kernel#Array(hash) would explode it into [[k, v], ...].
|
|
406
|
+
Array.wrap(request_body).filter_map { |req| req.is_a?(Hash) ? req["method"] : nil }
|
|
407
|
+
end
|
|
408
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A `tool_provider` (the dispatcher's api-agnostic seam) that serves the four
|
|
4
|
+
# GENERIC, Registry-backed tools — `resources`, `resource_schema`, `get`, `list` —
|
|
5
|
+
# over the resources a host registered in `config.registry`. It is the authority-
|
|
6
|
+
# path counterpart to the satellite's SDK tools (McpToolkit::Tools::*): same
|
|
7
|
+
# generic contract (discover resources, learn a shape, read one/many rows, all
|
|
8
|
+
# account-scoped and read-only), but plugged into the hand-rolled dispatcher.
|
|
9
|
+
#
|
|
10
|
+
# Satisfies the provider contract the dispatcher calls:
|
|
11
|
+
# tool_definitions(context) -> the four static generic tool definitions
|
|
12
|
+
# find(name) -> a tool instance bound to this config, or nil
|
|
13
|
+
#
|
|
14
|
+
# The top-level definitions are context-independent (per-resource visibility and
|
|
15
|
+
# scope are enforced inside each tool at call time), so `tool_definitions` ignores
|
|
16
|
+
# the context. Compose this with bespoke host tools via CompositeToolProvider.
|
|
17
|
+
class McpToolkit::Authority::RegistryToolProvider
|
|
18
|
+
# BASE tool name (the api-agnostic identity of each generic tool) => the tool
|
|
19
|
+
# class. The four generic read tools; nothing here names an app concept. The name
|
|
20
|
+
# actually advertised in `tools/list` and matched in `tools/call` is this base
|
|
21
|
+
# name PREFIXED with `config.generic_tool_name_prefix` (empty by default, so the
|
|
22
|
+
# bare base name), letting a host namespace its generic tools.
|
|
23
|
+
TOOLS = {
|
|
24
|
+
"resources" => McpToolkit::Authority::Tools::Resources,
|
|
25
|
+
"resource_schema" => McpToolkit::Authority::Tools::ResourceSchema,
|
|
26
|
+
"get" => McpToolkit::Authority::Tools::Get,
|
|
27
|
+
"list" => McpToolkit::Authority::Tools::List
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
def initialize(config:)
|
|
31
|
+
@config = config
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The four static generic tool definitions (context-independent), each advertised
|
|
35
|
+
# under its PREFIXED name so `tools/list` shows the host's namespaced names.
|
|
36
|
+
def tool_definitions(_context)
|
|
37
|
+
TOOLS.map { |base_name, klass| klass.definition.merge(name: prefixed(base_name)) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A tool instance bound to this provider's config, or nil for an unknown name.
|
|
41
|
+
# The incoming name is matched against the PREFIXED names: the prefix is stripped
|
|
42
|
+
# to recover the base tool, so a name that does not carry the configured prefix
|
|
43
|
+
# (e.g. a sibling provider's unprefixed tool) is left for another provider.
|
|
44
|
+
def find(name)
|
|
45
|
+
base_name = base_name_for(name.to_s)
|
|
46
|
+
klass = base_name && TOOLS[base_name]
|
|
47
|
+
klass&.new(config: @config)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# The host's generic tool-name prefix (empty by default).
|
|
53
|
+
def prefix
|
|
54
|
+
@config.generic_tool_name_prefix.to_s
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The advertised name for a base tool: the configured prefix followed by the base.
|
|
58
|
+
def prefixed(base_name)
|
|
59
|
+
"#{prefix}#{base_name}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Recovers the base tool name from an advertised name by stripping the configured
|
|
63
|
+
# prefix, or nil when the name does not carry the (non-empty) prefix.
|
|
64
|
+
def base_name_for(name)
|
|
65
|
+
return name if prefix.empty?
|
|
66
|
+
return nil unless name.start_with?(prefix)
|
|
67
|
+
|
|
68
|
+
name.delete_prefix(prefix)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/integer/time"
|
|
4
|
+
|
|
5
|
+
# Generic, ownership-agnostic machinery for an AUTHORITY's access-token model,
|
|
6
|
+
# packaged as a concern to `include` into a host's ActiveRecord token model. It
|
|
7
|
+
# extracts everything about a token that is NOT specific to the host's tenancy
|
|
8
|
+
# model (how a token maps to accounts/users), leaving the host to declare only its
|
|
9
|
+
# ownership associations, account resolution, and any bespoke validations.
|
|
10
|
+
#
|
|
11
|
+
# Expected columns on the including model:
|
|
12
|
+
# token_digest :string NOT NULL, unique — SHA256(plaintext) for O(1) lookup
|
|
13
|
+
# token_prefix :string NOT NULL — first 11 plaintext chars, safe to display
|
|
14
|
+
# scopes :string[] / json — OAuth-style "<app>__<action>" grants (nullable)
|
|
15
|
+
# expires_at :datetime nullable — nil = never expires
|
|
16
|
+
# last_used_at :datetime nullable — throttled touch (see #touch_last_used!)
|
|
17
|
+
#
|
|
18
|
+
# What it provides:
|
|
19
|
+
# * Secure token generation on create (`assign_token`) + the plaintext reader
|
|
20
|
+
# (`#token`, populated only on the instance that generated it).
|
|
21
|
+
# * Lookup/verification: `.authenticate(plaintext)` / `.digest_for(plaintext)`.
|
|
22
|
+
# * Lifecycle scopes (`active` / `expired` / `never_used` / `used_within`) and
|
|
23
|
+
# `#expired?`, plus the throttled `#touch_last_used!`.
|
|
24
|
+
# * OAuth-style scope helpers (`#normalized_scopes` / `#authorized_for_scope?` /
|
|
25
|
+
# `#scope_restricted?`).
|
|
26
|
+
#
|
|
27
|
+
# Why a plain SHA256 (not bcrypt/scrypt/argon2): the token is a high-entropy random
|
|
28
|
+
# secret (24 bytes over a 64-char alphabet ≈ 144 bits), so rainbow tables can't
|
|
29
|
+
# exist and brute force is infeasible — the slow KDFs that protect low-entropy
|
|
30
|
+
# passwords buy nothing and would break the O(1) `find_by(token_digest:)` lookup.
|
|
31
|
+
module McpToolkit::Authority::Token
|
|
32
|
+
extend ActiveSupport::Concern
|
|
33
|
+
|
|
34
|
+
# Plaintext token layout. "mcp_" is the MCP-generic scheme prefix (not a host
|
|
35
|
+
# fingerprint); the display length is that prefix plus 7 random chars.
|
|
36
|
+
TOKEN_PREFIX = "mcp_"
|
|
37
|
+
RAW_TOKEN_BYTES = 24
|
|
38
|
+
TOKEN_PREFIX_DISPLAY_LENGTH = 11
|
|
39
|
+
|
|
40
|
+
# Skip the last_used_at UPDATE unless this much time has passed, so a burst of
|
|
41
|
+
# calls doesn't write on every request.
|
|
42
|
+
LAST_USED_AT_THROTTLE = 1.minute
|
|
43
|
+
|
|
44
|
+
included do
|
|
45
|
+
# The plaintext token: only populated on the in-memory instance that just
|
|
46
|
+
# generated it; nil on any reloaded/queried instance (we never store plaintext).
|
|
47
|
+
attr_reader :token
|
|
48
|
+
|
|
49
|
+
# Defense-in-depth presence checks (the columns are NOT NULL at the DB level);
|
|
50
|
+
# the host adds its own uniqueness enforcement suited to its stack.
|
|
51
|
+
validates :token_digest, presence: true
|
|
52
|
+
validates :token_prefix, presence: true
|
|
53
|
+
|
|
54
|
+
before_validation :assign_token, on: :create
|
|
55
|
+
|
|
56
|
+
scope :active, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
|
57
|
+
scope :expired, -> { where("expires_at IS NOT NULL AND expires_at <= ?", Time.current) }
|
|
58
|
+
scope :never_used, -> { where(last_used_at: nil) }
|
|
59
|
+
scope :used_within, ->(duration) { where(last_used_at: duration.ago..) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
module ClassMethods
|
|
63
|
+
# Looks up an active token by its plaintext value; nil for blank/unknown/expired.
|
|
64
|
+
def authenticate(plaintext)
|
|
65
|
+
return nil if plaintext.blank?
|
|
66
|
+
|
|
67
|
+
active.find_by(token_digest: digest_for(plaintext))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def digest_for(plaintext)
|
|
71
|
+
Digest::SHA256.hexdigest(plaintext)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def reload(...)
|
|
76
|
+
@token = nil
|
|
77
|
+
super
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def expired?
|
|
81
|
+
expires_at.present? && expires_at <= Time.current
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The OAuth-style scopes granted to this token as a clean array of "<app>__<action>"
|
|
85
|
+
# strings. An unrestricted token (NULL/empty `scopes`) returns [].
|
|
86
|
+
def normalized_scopes
|
|
87
|
+
Array(scopes).compact_blank
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Per-scope check. A tool requiring no scope is reachable by any token; a tool
|
|
91
|
+
# requiring a scope needs the token to HOLD that exact scope. An unrestricted
|
|
92
|
+
# token holds NO scopes, so it can reach only no-scope tools.
|
|
93
|
+
def authorized_for_scope?(scope)
|
|
94
|
+
return true if scope.blank?
|
|
95
|
+
|
|
96
|
+
normalized_scopes.include?(scope.to_s)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# True when the token carries an explicit scope set (i.e. is restricted).
|
|
100
|
+
def scope_restricted?
|
|
101
|
+
normalized_scopes.any?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Throttled last_used_at bump: persists on its own, without validations or
|
|
105
|
+
# bumping updated_at, and only once per LAST_USED_AT_THROTTLE window.
|
|
106
|
+
def touch_last_used!
|
|
107
|
+
return unless last_used_at.nil? || last_used_at < LAST_USED_AT_THROTTLE.ago
|
|
108
|
+
|
|
109
|
+
self.last_used_at = Time.current
|
|
110
|
+
save!(validate: false, touch: false)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def assign_token
|
|
116
|
+
return if token_digest.present?
|
|
117
|
+
|
|
118
|
+
@token = "#{TOKEN_PREFIX}#{SecureRandom.urlsafe_base64(RAW_TOKEN_BYTES)}"
|
|
119
|
+
self.token_digest = self.class.digest_for(@token)
|
|
120
|
+
# Plain-Ruby slice (not String#first) so the concern needs no ActiveSupport
|
|
121
|
+
# string core-ext; the token is always longer than the display length.
|
|
122
|
+
self.token_prefix = @token[0, TOKEN_PREFIX_DISPLAY_LENGTH]
|
|
123
|
+
end
|
|
124
|
+
end
|