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,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