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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +43 -0
- data/.rubocop.yml +98 -0
- data/CHANGELOG.md +123 -0
- data/LICENSE.txt +21 -0
- data/README.md +301 -0
- data/Rakefile +21 -0
- data/app/controllers/mcp_toolkit/server_controller.rb +19 -0
- data/config/routes.rb +18 -0
- data/lib/mcp_toolkit/auth/authenticator.rb +99 -0
- data/lib/mcp_toolkit/auth/authority.rb +75 -0
- data/lib/mcp_toolkit/auth/authority_server_client.rb +50 -0
- data/lib/mcp_toolkit/auth/introspection.rb +162 -0
- data/lib/mcp_toolkit/configuration.rb +209 -0
- data/lib/mcp_toolkit/engine.rb +21 -0
- data/lib/mcp_toolkit/errors/base.rb +10 -0
- data/lib/mcp_toolkit/errors/configuration_error.rb +5 -0
- data/lib/mcp_toolkit/errors/invalid_params.rb +5 -0
- data/lib/mcp_toolkit/errors/unauthorized.rb +5 -0
- data/lib/mcp_toolkit/field_selection.rb +93 -0
- data/lib/mcp_toolkit/filtering.rb +152 -0
- data/lib/mcp_toolkit/get_executor.rb +32 -0
- data/lib/mcp_toolkit/list_executor.rb +137 -0
- data/lib/mcp_toolkit/registry.rb +67 -0
- data/lib/mcp_toolkit/resource.rb +129 -0
- data/lib/mcp_toolkit/resource_schema.rb +163 -0
- data/lib/mcp_toolkit/serialization.rb +62 -0
- data/lib/mcp_toolkit/serializer/base.rb +285 -0
- data/lib/mcp_toolkit/server.rb +44 -0
- data/lib/mcp_toolkit/session.rb +46 -0
- data/lib/mcp_toolkit/sql_sanitizer.rb +14 -0
- data/lib/mcp_toolkit/token_kinds.rb +14 -0
- data/lib/mcp_toolkit/tools/base.rb +100 -0
- data/lib/mcp_toolkit/tools/get.rb +57 -0
- data/lib/mcp_toolkit/tools/list.rb +83 -0
- data/lib/mcp_toolkit/tools/resource_schema.rb +40 -0
- data/lib/mcp_toolkit/tools/resources.rb +27 -0
- data/lib/mcp_toolkit/transport/controller_methods.rb +226 -0
- data/lib/mcp_toolkit/unknown_resource_message.rb +75 -0
- data/lib/mcp_toolkit/version.rb +5 -0
- data/lib/mcp_toolkit.rb +118 -0
- data/sig/mcp_toolkit.rbs +4 -0
- 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
|