mcp_toolkit 0.3.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 (43) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +43 -0
  3. data/.rubocop.yml +98 -0
  4. data/CHANGELOG.md +123 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +301 -0
  7. data/Rakefile +21 -0
  8. data/app/controllers/mcp_toolkit/server_controller.rb +19 -0
  9. data/config/routes.rb +18 -0
  10. data/lib/mcp_toolkit/auth/authenticator.rb +99 -0
  11. data/lib/mcp_toolkit/auth/authority.rb +75 -0
  12. data/lib/mcp_toolkit/auth/authority_server_client.rb +50 -0
  13. data/lib/mcp_toolkit/auth/introspection.rb +162 -0
  14. data/lib/mcp_toolkit/configuration.rb +209 -0
  15. data/lib/mcp_toolkit/engine.rb +21 -0
  16. data/lib/mcp_toolkit/errors/base.rb +10 -0
  17. data/lib/mcp_toolkit/errors/configuration_error.rb +5 -0
  18. data/lib/mcp_toolkit/errors/invalid_params.rb +5 -0
  19. data/lib/mcp_toolkit/errors/unauthorized.rb +5 -0
  20. data/lib/mcp_toolkit/field_selection.rb +93 -0
  21. data/lib/mcp_toolkit/filtering.rb +152 -0
  22. data/lib/mcp_toolkit/get_executor.rb +32 -0
  23. data/lib/mcp_toolkit/list_executor.rb +137 -0
  24. data/lib/mcp_toolkit/registry.rb +67 -0
  25. data/lib/mcp_toolkit/resource.rb +129 -0
  26. data/lib/mcp_toolkit/resource_schema.rb +163 -0
  27. data/lib/mcp_toolkit/serialization.rb +62 -0
  28. data/lib/mcp_toolkit/serializer/base.rb +285 -0
  29. data/lib/mcp_toolkit/server.rb +44 -0
  30. data/lib/mcp_toolkit/session.rb +46 -0
  31. data/lib/mcp_toolkit/sql_sanitizer.rb +14 -0
  32. data/lib/mcp_toolkit/token_kinds.rb +14 -0
  33. data/lib/mcp_toolkit/tools/base.rb +100 -0
  34. data/lib/mcp_toolkit/tools/get.rb +57 -0
  35. data/lib/mcp_toolkit/tools/list.rb +83 -0
  36. data/lib/mcp_toolkit/tools/resource_schema.rb +40 -0
  37. data/lib/mcp_toolkit/tools/resources.rb +27 -0
  38. data/lib/mcp_toolkit/transport/controller_methods.rb +226 -0
  39. data/lib/mcp_toolkit/unknown_resource_message.rb +75 -0
  40. data/lib/mcp_toolkit/version.rb +5 -0
  41. data/lib/mcp_toolkit.rb +118 -0
  42. data/sig/mcp_toolkit.rbs +4 -0
  43. metadata +147 -0
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SATELLITE side. Resolves the authenticated, scoped context for a tool call:
4
+ #
5
+ # 1. Introspect the bearer token against the central app (cached).
6
+ # 2. Reject if invalid / expired.
7
+ # 3. Resolve the active central account id, enforcing tenancy:
8
+ # - accounts_user token => its single bound `account_id`. A supplied
9
+ # selector, if present, MUST match it.
10
+ # - user (superuser) token => the selector is REQUIRED and MUST be one of
11
+ # the token's `account_ids`.
12
+ # 4. Map that central account id to the LOCAL scope root via
13
+ # `config.account_resolver` (e.g. Account.find_by(synced_id:)) and return
14
+ # it as the tools' `scope_root`.
15
+ #
16
+ # The required scope (explicitly declared per resource via
17
+ # `required_permissions_scope`, or the registry default) is enforced separately
18
+ # by Tools::Base (#with_account / #with_authentication) via
19
+ # `authorized_for_scope?`; the authenticator only validates the token and
20
+ # resolves the tenant.
21
+ #
22
+ # The account selector mirrors what a gateway forwards: the resolved account id
23
+ # arrives as `_meta[config.account_meta_key]`. We also accept an `account_id`
24
+ # tool argument and the `config.account_id_header` header as fallbacks.
25
+ class McpToolkit::Auth::Authenticator
26
+ Context = Struct.new(:scope_root, :introspection, keyword_init: true)
27
+
28
+ # @param token [String] the plaintext bearer the central app forwarded
29
+ # @param meta [Hash] the JSON-RPC `_meta` (string or symbol keys)
30
+ # @param arguments [Hash] the tool-call arguments (may carry `account_id`)
31
+ # @param header_account_id [Integer,String,nil] the account-id header value
32
+ # @param config [McpToolkit::Configuration]
33
+ def self.call(token:, meta: {}, arguments: {}, header_account_id: nil, config: McpToolkit.config)
34
+ new(token:, meta:, arguments:, header_account_id:, config:).call
35
+ end
36
+
37
+ def initialize(token:, meta:, arguments:, header_account_id:, config: McpToolkit.config)
38
+ @token = token
39
+ @meta = (meta || {}).transform_keys(&:to_s)
40
+ @arguments = (arguments || {}).transform_keys(&:to_s)
41
+ @header_account_id = header_account_id
42
+ @config = config
43
+ end
44
+
45
+ def call
46
+ introspection = McpToolkit::Auth::Introspection.call(token, config:)
47
+ raise McpToolkit::Errors::Unauthorized, "invalid or expired token" unless introspection.valid?
48
+
49
+ central_account_id = resolve_account_id(introspection)
50
+ scope_root = config.account_resolver.call(central_account_id)
51
+ unless scope_root
52
+ raise McpToolkit::Errors::Unauthorized, "no local scope found for account_id=#{central_account_id}"
53
+ end
54
+
55
+ Context.new(scope_root:, introspection:)
56
+ end
57
+
58
+ private
59
+
60
+ attr_reader :token, :meta, :arguments, :header_account_id, :config
61
+
62
+ def resolve_account_id(introspection)
63
+ candidate = candidate_account_id
64
+
65
+ if introspection.accounts_user?
66
+ bound = introspection.account_id
67
+ # Compare as STRINGS: ids may be numeric or string/UUID, and to_i would
68
+ # collapse every non-numeric id to 0 (letting "acct_B" match "acct_A").
69
+ if candidate.present? && candidate.to_s != bound.to_s
70
+ raise McpToolkit::Errors::Unauthorized,
71
+ "account_id #{candidate} does not match this token's bound account"
72
+ end
73
+
74
+ return bound.to_s
75
+ end
76
+
77
+ # superuser / multi-account: selection is mandatory and must be authorized.
78
+ if candidate.blank?
79
+ raise McpToolkit::Errors::Unauthorized,
80
+ "this token spans multiple accounts; an account must be selected " \
81
+ "via _meta[\"#{config.account_meta_key}\"] (or the account_id argument)"
82
+ end
83
+
84
+ # String-normalized membership (see accounts_user branch): `candidate` may be
85
+ # an Integer (forwarded `_meta` JSON number) or a String (header/arg); to_s
86
+ # normalizes both and authorized_account_ids is likewise string-normalized.
87
+ unless introspection.authorized_account_ids.include?(candidate.to_s)
88
+ raise McpToolkit::Errors::Unauthorized, "account_id #{candidate} is not authorized for this token"
89
+ end
90
+
91
+ candidate.to_s
92
+ end
93
+
94
+ def candidate_account_id
95
+ meta[config.account_meta_key].presence ||
96
+ arguments["account_id"].presence ||
97
+ header_account_id.presence
98
+ end
99
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ # AUTHORITY side. The helpers the central app uses to (a) authenticate a
4
+ # plaintext bearer token against its local token store and (b) answer the
5
+ # introspection request satellites send.
6
+ #
7
+ # Both are thin and config-driven: the actual token lookup is the app's
8
+ # `config.token_authenticator` callable (its `McpToken.authenticate`
9
+ # equivalent), and the introspection payload is derived from the duck-typed
10
+ # token object that callable returns.
11
+ #
12
+ # ## Token object contract
13
+ #
14
+ # `config.token_authenticator.call(plaintext)` must return nil (no/invalid
15
+ # token) or an object responding to:
16
+ #
17
+ # #kind -> :accounts_user | :user (or string equivalents)
18
+ # #account_id -> the single bound account id for an accounts_user token,
19
+ # else nil
20
+ # #account_ids -> Array of authorized account ids
21
+ # #expires_at -> a Time/DateTime responding to #iso8601, or nil
22
+ # #scopes -> Array of OAuth-style `<app>__<action>` scopes ([] = no scopes).
23
+ # The sole authorization source on the satellite side.
24
+ #
25
+ # Optionally `#touch_last_used!` (called after a successful authenticate if
26
+ # present). A typical app token model (e.g. `McpToken`) satisfies this.
27
+ module McpToolkit::Auth::Authority
28
+ # Authenticate a plaintext bearer locally. Returns the token object or nil.
29
+ # Calls `touch_last_used!` on the token if it responds to it (throttled
30
+ # last-used tracking is the token model's concern, not ours).
31
+ def self.authenticate(plaintext, config: McpToolkit.config)
32
+ authenticator = config.token_authenticator
33
+ if authenticator.nil?
34
+ raise McpToolkit::Errors::ConfigurationError,
35
+ "token_authenticator is not configured; required for the :authority role"
36
+ end
37
+
38
+ token = authenticator.call(plaintext)
39
+ return nil unless token
40
+
41
+ token.touch_last_used! if token.respond_to?(:touch_last_used!)
42
+ token
43
+ end
44
+
45
+ # Build the introspection response payload for a token object. This is the
46
+ # JSON the authority's `/mcp/tokens/introspect` endpoint renders, and the
47
+ # exact contract Auth::Introspection (the satellite) parses.
48
+ #
49
+ # @param token [#kind, #account_id, #account_ids, #expires_at, #scopes]
50
+ # @return [Hash]
51
+ def self.introspection_payload(token)
52
+ account_ids = Array(token.account_ids)
53
+ {
54
+ valid: true,
55
+ kind: token.kind.to_s,
56
+ account_id: account_id_for(token, account_ids),
57
+ account_ids:,
58
+ expires_at: token.expires_at&.iso8601,
59
+ scopes: Array(token.scopes)
60
+ }
61
+ end
62
+
63
+ # The payload returned for a missing/invalid token. Render with HTTP 401.
64
+ def self.invalid_payload
65
+ { valid: false }
66
+ end
67
+
68
+ # account_id is the single bound account for an accounts_user token, else nil
69
+ # (a superuser/multi-account token advertises its set via account_ids).
70
+ def self.account_id_for(token, account_ids)
71
+ return token.account_id unless token.account_id.nil?
72
+
73
+ token.kind.to_s == McpToolkit::TokenKinds::ACCOUNTS_USER ? account_ids.first : nil
74
+ end
75
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+
5
+ # SATELLITE side. The HTTP client that talks to the central authority's
6
+ # introspection endpoint. Owns the Faraday connection (timeouts, JSON parsing,
7
+ # adapter) and the POST itself, so Auth::Introspection is left with the caching
8
+ # and result-parsing concerns only.
9
+ #
10
+ # POST {config.introspect_url}
11
+ # Authorization: Bearer <token>
12
+ # Accept: application/json
13
+ #
14
+ # Returns the raw response body on a 200, or nil otherwise (a non-200 status or a
15
+ # transport-level Faraday error). Auth::Introspection turns that into an
16
+ # Introspection::Result.
17
+ class McpToolkit::Auth::AuthorityServerClient
18
+ def initialize(config)
19
+ @config = config
20
+ end
21
+
22
+ # POSTs the token to the authority's introspection endpoint. Returns the
23
+ # response body on a 200; nil on any non-200 status or transport failure.
24
+ def introspect(token)
25
+ response = connection.post(config.introspect_url) do |request|
26
+ request.headers["Authorization"] = "Bearer #{token}"
27
+ request.headers["Accept"] = "application/json"
28
+ end
29
+
30
+ return nil unless response.status == 200
31
+
32
+ response.body
33
+ rescue Faraday::Error
34
+ nil
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :config
40
+
41
+ def connection
42
+ timeout = config.introspection_timeout
43
+ Faraday.new do |conn|
44
+ conn.options.timeout = timeout
45
+ conn.options.open_timeout = timeout
46
+ conn.response :json, content_type: /\bjson$/
47
+ conn.adapter Faraday.default_adapter
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SATELLITE side. Authenticates the bearer token the central app forwards by
4
+ # calling the central app's introspection endpoint, with a short-TTL cache so a
5
+ # burst of tool calls in one session does not hammer the central app.
6
+ #
7
+ # POST {central_app_url}{introspect_path}
8
+ # Authorization: Bearer <token>
9
+ #
10
+ # Response contract (the AUTHORITY emits this; see Auth::Authority):
11
+ # { valid: bool,
12
+ # kind: <one of McpToolkit::TokenKinds — accounts-user or superuser>,
13
+ # account_id: <id|null>,
14
+ # account_ids: [...],
15
+ # expires_at: <iso8601|null>,
16
+ # scopes: [...] } # OAuth-style `<app>__<action>` scopes a token carries
17
+ #
18
+ # Authorization is purely scope-based: a token reaches a tool when it carries the
19
+ # scope that tool explicitly requires (declared per resource via
20
+ # `required_permissions_scope`, or the registry default; enforced in
21
+ # Tools::Base#with_account / #with_authentication via `authorized_for_scope?`).
22
+ #
23
+ # The cache is keyed on a SHA-256 of the token (never the plaintext) so cached
24
+ # entries can't be reversed back to a usable credential from cache storage. The
25
+ # HTTP call itself is delegated to Auth::AuthorityServerClient.
26
+ class McpToolkit::Auth::Introspection
27
+ CACHE_PREFIX = "mcp_toolkit:introspection:"
28
+
29
+ Result = Struct.new(
30
+ :valid, :kind, :account_id, :account_ids, :expires_at, :scopes, keyword_init: true
31
+ ) do
32
+ # Locally enforce the authority's `expires_at` (defense-in-depth): a token is
33
+ # only valid when the authority said so AND it has not lapsed. Without this,
34
+ # validity would rest solely on the authority's `valid: true` boolean, so a
35
+ # stale `valid: true` (e.g. a clock-skewed or buggy authority, or a cached
36
+ # Result) with a past `expires_at` would be accepted indefinitely.
37
+ def valid?
38
+ valid == true && !expired?
39
+ end
40
+
41
+ # True when `expires_at` is present and now is at/after it. Blank/nil means
42
+ # the token has no expiry (=> not expired). An UNPARSEABLE `expires_at` is
43
+ # treated as expired (fail-closed) rather than silently accepted. Kept public
44
+ # for clarity/testability.
45
+ def expired?
46
+ raw = expires_at
47
+ return false if raw.nil? || raw.to_s.strip.empty?
48
+
49
+ expiry = parse_expiry(raw)
50
+ return true if expiry.nil? # unparseable => fail closed
51
+
52
+ expiry <= Time.now
53
+ end
54
+
55
+ private
56
+
57
+ def parse_expiry(raw)
58
+ return raw if raw.is_a?(Time)
59
+
60
+ Time.iso8601(raw.to_s)
61
+ rescue ArgumentError, TypeError
62
+ begin
63
+ Time.parse(raw.to_s)
64
+ rescue ArgumentError, TypeError
65
+ nil
66
+ end
67
+ end
68
+
69
+ public
70
+
71
+ def accounts_user?
72
+ kind.to_s == McpToolkit::TokenKinds::ACCOUNTS_USER
73
+ end
74
+
75
+ def superuser?
76
+ kind.to_s == McpToolkit::TokenKinds::USER
77
+ end
78
+
79
+ # True when the token carries the EXACT `required_scope` (e.g.
80
+ # `notifications__read`). An empty required scope passes (a tool that
81
+ # requires no scope is reachable by any valid token). A non-empty required
82
+ # scope must be present in the token's `scopes`; empty token scopes are
83
+ # therefore unrestricted ONLY for tools that require no scope.
84
+ def authorized_for_scope?(required_scope)
85
+ return true if required_scope.to_s.empty?
86
+
87
+ scope_list = Array(scopes).map(&:to_s)
88
+ scope_list.include?(required_scope.to_s)
89
+ end
90
+
91
+ # Account ids are STRING-normalized for comparison: the contract allows
92
+ # integer OR string/UUID ids, so coercing to_i would collapse every
93
+ # non-numeric id to 0 and let unrelated accounts match. Strings compare
94
+ # safely and the resolver's `find_by(synced_id:)` coerces back per-column.
95
+ def authorized_account_ids
96
+ Array(account_ids).map(&:to_s)
97
+ end
98
+ end
99
+
100
+ INVALID = Result.new(valid: false).freeze
101
+
102
+ # Returns an Introspection::Result. Invalid/expired/unreachable => a result
103
+ # whose `valid?` is false. Caches positive AND negative results briefly.
104
+ def self.call(token, config: McpToolkit.config)
105
+ new(token, config:).call
106
+ end
107
+
108
+ def initialize(token, config: McpToolkit.config)
109
+ @token = token
110
+ @config = config
111
+ end
112
+
113
+ def call
114
+ return INVALID if token.to_s.empty?
115
+
116
+ cached = cache.read(cache_key)
117
+ return cached if cached
118
+
119
+ result = fetch
120
+ cache.write(cache_key, result, expires_in: config.introspection_cache_ttl)
121
+ result
122
+ end
123
+
124
+ private
125
+
126
+ attr_reader :token, :config
127
+
128
+ def fetch
129
+ body = authority_server_client.introspect(token)
130
+ return INVALID if body.nil?
131
+
132
+ parse(body)
133
+ end
134
+
135
+ def parse(body)
136
+ payload = body.is_a?(Hash) ? body : JSON.parse(body)
137
+ return INVALID unless payload["valid"] == true
138
+
139
+ Result.new(
140
+ valid: true,
141
+ kind: payload["kind"],
142
+ account_id: payload["account_id"],
143
+ account_ids: payload["account_ids"],
144
+ expires_at: payload["expires_at"],
145
+ scopes: payload["scopes"]
146
+ )
147
+ rescue JSON::ParserError
148
+ INVALID
149
+ end
150
+
151
+ def authority_server_client
152
+ McpToolkit::Auth::AuthorityServerClient.new(config)
153
+ end
154
+
155
+ def cache
156
+ config.cache_store
157
+ end
158
+
159
+ def cache_key
160
+ "#{CACHE_PREFIX}#{Digest::SHA256.hexdigest(token)}"
161
+ end
162
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The single, injectable configuration object for an app's MCP server.
4
+ #
5
+ # Generic but OPINIONATED: every setting has a sensible, vendor-neutral default,
6
+ # so a satellite needs to override only a handful of values. The two things an
7
+ # app almost always sets are
8
+ # `server_name` and the auth wiring (`central_app_url` for a satellite, or
9
+ # `token_authenticator` for the authority).
10
+ #
11
+ # Whether ANY scope is required is decided PER TOOL, not per app: a resource
12
+ # declares `required_permissions_scope "notifications__read"` (or the registry
13
+ # declares `default_required_permissions_scope` once for all resources). There is
14
+ # no app-wide permission setting here.
15
+ #
16
+ # Accessed through `McpToolkit.config` (or `MCPToolkit.config`) and mutated in a
17
+ # `McpToolkit.configure { |c| ... }` block.
18
+ class McpToolkit::Configuration
19
+ # --- server identity -------------------------------------------------------
20
+
21
+ # @return [String] the MCP server name advertised in `initialize`.
22
+ attr_accessor :server_name
23
+ # @return [String] the MCP server version advertised in `initialize`.
24
+ attr_accessor :server_version
25
+ # @return [String, nil] human-readable `instructions` returned on `initialize`.
26
+ attr_accessor :server_instructions
27
+
28
+ # --- serialization ---------------------------------------------------------
29
+
30
+ # The DEFAULT serializer base class. A `Resource` registration that does not
31
+ # supply its own `serializer` inherits nothing here — serializers are picked
32
+ # per-resource — but this is the class the gem ships and documents as the base
33
+ # to subclass. Apps that want their own existing serializers simply register
34
+ # resources with those classes instead; the gem only requires that a serializer
35
+ # responds to `serialize_one` / `serialize_collection` (see
36
+ # McpToolkit::Serializer::Base for the contract).
37
+ #
38
+ # @return [Class]
39
+ # The reader is defined below as a lazily-defaulting method; only the writer
40
+ # comes from here.
41
+ attr_writer :serializer_base
42
+
43
+ # --- auth: role ------------------------------------------------------------
44
+
45
+ # @return [Symbol] :satellite (introspect tokens against a central app) or
46
+ # :authority (be the introspection provider + authenticate local tokens).
47
+ # A single app MAY be both — set :authority and still configure a
48
+ # `central_app_url` if it also exposes its own tools as a satellite.
49
+ attr_accessor :auth_role
50
+
51
+ # --- auth: satellite side --------------------------------------------------
52
+
53
+ # @return [String, nil] base URL of the central auth app.
54
+ # The satellite POSTs `<central_app_url>/mcp/tokens/introspect`.
55
+ attr_accessor :central_app_url
56
+ # @return [String, nil] the introspect path appended to `central_app_url`.
57
+ attr_accessor :introspect_path
58
+ # @return [Integer] seconds to cache an introspection result (positive AND
59
+ # negative) so a burst of tool calls does not hammer the central app.
60
+ attr_accessor :introspection_cache_ttl
61
+ # @return [Integer] HTTP open/read timeout for the introspection call.
62
+ attr_accessor :introspection_timeout
63
+
64
+ # Resolves the central account id to the satellite's LOCAL scope root.
65
+ #
66
+ # A satellite stores rows keyed by the central app's account id (synced via
67
+ # Kafka etc.). This callable receives the resolved central `account_id` and
68
+ # MUST return the object that `Resource#scope` blocks root on (typically the
69
+ # local `Account`). Return nil to signal "no local account" (=> Unauthorized).
70
+ #
71
+ # c.account_resolver = ->(synced_account_id) { Account.find_by(synced_id: synced_account_id) }
72
+ #
73
+ # Defaults to the identity function: the resolved central account id is used
74
+ # directly as the scope root (suitable for an app whose scope blocks key on the
75
+ # central id, or for the authority itself).
76
+ #
77
+ # @return [#call]
78
+ attr_accessor :account_resolver
79
+
80
+ # --- auth: authority side --------------------------------------------------
81
+
82
+ # Looks up + verifies a plaintext bearer token locally, returning a token
83
+ # object (duck-typed, see below) or nil. This is the authority's
84
+ # `McpToken.authenticate(plaintext)` equivalent. Required for the :authority
85
+ # role; unused by a pure satellite.
86
+ #
87
+ # c.token_authenticator = ->(plaintext) { McpToken.authenticate(plaintext) }
88
+ #
89
+ # The returned token object must respond to the methods
90
+ # `McpToolkit::Auth::Authority#introspection_payload` reads (see that module for
91
+ # the contract): `kind`, `account_id`, `account_ids`, `expires_at`, `scopes`. A
92
+ # `touch_last_used!` method, if present, is called.
93
+ #
94
+ # @return [#call, nil]
95
+ attr_accessor :token_authenticator
96
+
97
+ # --- caching ---------------------------------------------------------------
98
+
99
+ # The cache store backing sessions and introspection results. Must satisfy the
100
+ # ActiveSupport::Cache::Store contract (`read`/`write`/`delete` with
101
+ # `expires_in:`). Defaults to an in-process MemoryStore; a real deployment
102
+ # should set this to `Rails.cache` (or any shared store) so sessions survive
103
+ # across Puma workers.
104
+ #
105
+ # @return [ActiveSupport::Cache::Store, #read]
106
+ attr_accessor :cache_store
107
+
108
+ # @return [Integer] session sliding-TTL in seconds.
109
+ attr_accessor :session_ttl
110
+
111
+ # --- filtering -------------------------------------------------------------
112
+
113
+ # Escapes LIKE wildcards in `matches` / `does_not_match` filter values so they
114
+ # match literally. Must respond to `sanitize_sql_like(string)`. Defaults to the
115
+ # ActiveRecord-backed McpToolkit::SqlSanitizer; a non-Rails host (or a test) can
116
+ # inject its own.
117
+ #
118
+ # @return [#sanitize_sql_like]
119
+ attr_accessor :sql_sanitizer
120
+
121
+ # --- protocol / transport --------------------------------------------------
122
+
123
+ # @return [String, nil] protocol version to pin on the underlying MCP::Server.
124
+ # nil lets the gem negotiate (recommended). Set only to force an older spec.
125
+ attr_accessor :protocol_version
126
+
127
+ # The parent class (as a String, resolved via `constantize`) of the
128
+ # gem-provided McpToolkit::ServerController that McpToolkit::Engine mounts.
129
+ # Doorkeeper-style indirection so a satellite mounting the engine can keep
130
+ # ActionController::Base (NOT ::API) — e.g. for a logstasher `helper_method`
131
+ # hook — by setting `c.parent_controller = "ApplicationController"`.
132
+ #
133
+ # @return [String]
134
+ attr_accessor :parent_controller
135
+
136
+ # Header / meta-key constants. Vendor-neutral defaults; an app on a specific
137
+ # central authority can rename them to match that authority's convention.
138
+ # These are the selectors a superuser/multi-account token uses to pin the
139
+ # active account.
140
+ #
141
+ # @return [String]
142
+ attr_accessor :account_meta_key
143
+ # @return [String]
144
+ attr_accessor :account_id_header
145
+
146
+ # The resource registry for this configuration. Each config carries its own so
147
+ # tests (and, in principle, multiple mounted servers) don't collide. The
148
+ # process-wide convenience `McpToolkit.registry` delegates to the active
149
+ # config's registry.
150
+ #
151
+ # @return [McpToolkit::Registry]
152
+ attr_accessor :registry
153
+
154
+ # Vendor-neutral defaults; apps override the auth wiring + identity as needed.
155
+ def initialize
156
+ @server_name = "mcp-server"
157
+ @server_version = "1.0.0"
158
+ @server_instructions = nil
159
+
160
+ @serializer_base = nil # set lazily in #serializer_base to avoid load-order issues
161
+
162
+ @auth_role = :satellite
163
+ @central_app_url = nil
164
+ @introspect_path = "/mcp/tokens/introspect"
165
+ @introspection_cache_ttl = 45
166
+ @introspection_timeout = 10
167
+ @account_resolver = ->(synced_account_id) { synced_account_id }
168
+
169
+ @token_authenticator = nil
170
+
171
+ @cache_store = ActiveSupport::Cache::MemoryStore.new
172
+ @session_ttl = 3600 # 1 hour
173
+
174
+ @sql_sanitizer = McpToolkit::SqlSanitizer.new
175
+
176
+ @protocol_version = nil
177
+ @parent_controller = "ActionController::Base"
178
+ @account_meta_key = "mcp-toolkit/account-id"
179
+ @account_id_header = "X-MCP-Account-ID"
180
+
181
+ @registry = McpToolkit::Registry.new
182
+ end
183
+
184
+ # The serializer base, lazily defaulting to the gem's bundled DSL base. Lazy so
185
+ # `McpToolkit::Serializer::Base` is referenced after it has been required,
186
+ # regardless of file load order.
187
+ def serializer_base
188
+ @serializer_base ||= McpToolkit::Serializer::Base
189
+ end
190
+
191
+ # @return [Boolean] whether this app introspects tokens against a central app.
192
+ def satellite?
193
+ auth_role.to_sym == :satellite || central_app_url
194
+ end
195
+
196
+ # @return [Boolean] whether this app authenticates tokens locally / answers
197
+ # introspection.
198
+ def authority?
199
+ auth_role.to_sym == :authority
200
+ end
201
+
202
+ # Full introspection URL the satellite POSTs to. Raises a clear error if the
203
+ # central URL was never configured.
204
+ def introspect_url
205
+ raise McpToolkit::Errors::ConfigurationError, "central_app_url is not configured" if central_app_url.to_s.empty?
206
+
207
+ "#{central_app_url.chomp("/")}#{introspect_path}"
208
+ end
209
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Mountable Rails engine that draws the four MCP transport routes (defined in the
4
+ # engine's config/routes.rb so they survive Rails' route reloads) against the
5
+ # gem-provided McpToolkit::ServerController. A satellite mounts it in one line:
6
+ #
7
+ # # config/routes.rb
8
+ # mount McpToolkit::Engine => "/mcp"
9
+ #
10
+ # yielding exactly the endpoints a hand-rolled satellite declared:
11
+ #
12
+ # POST /mcp -> create (JSON-RPC requests/responses)
13
+ # GET /mcp -> stream (405; no server-initiated SSE)
14
+ # DELETE /mcp -> destroy (terminate the session)
15
+ # GET /mcp/health -> health (unauthenticated probe)
16
+ #
17
+ # Loaded ONLY when Rails::Engine is available (see lib/mcp_toolkit.rb); the gem's
18
+ # non-Rails consumers and its own unit suite never reference it.
19
+ class McpToolkit::Engine < Rails::Engine
20
+ isolate_namespace McpToolkit
21
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Tool-level errors raised by executors / auth and caught at the tool boundary,
4
+ # where they are turned into an `isError: true` MCP tool result (NOT a JSON-RPC
5
+ # protocol error). This matches how MCP clients (and a gateway relaying the
6
+ # satellite's `result` verbatim) expect tool failures to surface: the call
7
+ # succeeds at the protocol level, the result carries the error.
8
+ #
9
+ # Base class for that family; subclasses below narrow the cause.
10
+ class McpToolkit::Errors::Base < StandardError; end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The toolkit was used before it was configured, or a required piece of
4
+ # configuration is missing for the operation being attempted.
5
+ class McpToolkit::Errors::ConfigurationError < McpToolkit::Errors::Base; end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The arguments to a tool were invalid (missing id, unknown resource, bad
4
+ # account selection, etc.).
5
+ class McpToolkit::Errors::InvalidParams < McpToolkit::Errors::Base; end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The caller is not authenticated / authorized (token invalid, expired, lacks
4
+ # the required permissions scope, or no/invalid account context).
5
+ class McpToolkit::Errors::Unauthorized < McpToolkit::Errors::Base; end