Skip to content

Commit f85ae47

Browse files
Azdarothclaude
andcommitted
Apply PR #1 review: scopes-only auth, Zeitwerk autoloading, API v3 filtering, flat declarations
- Authorization is now purely scope-based: drop authorized_for_application?, the :applications introspection field, and the app-reach check in the authenticator + Tools::Base#with_authentication. Net effect unchanged for valid traffic (scope check alone still enforces <app>__<action>). - Authority#introspection_payload emits scopes via Array(token.scopes) directly (token must always respond to #scopes; drop the respond_to? guard and the applications field). - Set up Zeitwerk (Zeitwerk::Loader.for_gem) and remove all manual require/require_relative of gem files; version.rb stays eagerly required for the gemspec; MCPToolkit alias preserved. - Port API v3 complex hash filtering into list_executor/resource via McpToolkit::Filtering: bare value = equality (comma => IN), { op:, value: } hash = operator filter, array of those = ANDed range; operators validated per column type; allowlist-safe (only filterable keys). - Order by :id only when the primary key is numeric, else :created_at. - Use flat (compact) leaf class declarations; GetExecutor uses attr_reader. - Remove all provenance/'extracted from' comments. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 51f2595 commit f85ae47

26 files changed

Lines changed: 1610 additions & 1361 deletions

.rubocop.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ Layout/LineLength:
1818

1919
# --- Style ------------------------------------------------------------------
2020

21+
# Prefer FLAT (compact) leaf declarations — `class McpToolkit::Auth::Authenticator`
22+
# — over the `module ... module ... class` pyramid. Files that genuinely define
23+
# several constants under one namespace (so the namespace must be opened as a
24+
# block) are excluded; there compact is not expressible.
25+
Style/ClassAndModuleChildren:
26+
EnforcedStyle: compact
27+
Exclude:
28+
- "lib/mcp_toolkit.rb"
29+
- "lib/mcp_toolkit/errors.rb"
30+
- "lib/mcp_toolkit/filtering.rb"
31+
- "lib/mcp_toolkit/serializer/base.rb"
32+
- "lib/mcp_toolkit/tools/base.rb"
33+
2134
# The gem (gemspec, Gemfile, lib, specs) is written with double-quoted strings;
2235
# match that convention instead of churning every literal.
2336
Style/StringLiterals:

lib/mcp_toolkit.rb

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# frozen_string_literal: true
22

3-
# ActiveSupport core extensions the extracted code relies on. Required up front
4-
# (not full Rails) so the gem works in any host: blank?/presence, deep_symbolize_keys,
3+
require "zeitwerk"
4+
5+
# ActiveSupport core extensions the toolkit relies on. Required up front (not full
6+
# Rails) so the gem works in any host: blank?/presence, deep_symbolize_keys,
57
# Array.wrap, compact_blank, iso8601 on Time/DateTime.
68
require "active_support"
79
require "active_support/core_ext/object/blank"
@@ -11,32 +13,15 @@
1113
require "active_support/core_ext/time/conversions"
1214
require "active_support/core_ext/date_time/conversions"
1315

16+
# The version constant is needed eagerly by the gemspec (before the loader is set
17+
# up), so it stays an explicit require rather than an autoload.
1418
require_relative "mcp_toolkit/version"
15-
require_relative "mcp_toolkit/errors"
16-
17-
# Load order matters: Registry + Serializer::Base are referenced by Configuration
18-
# (the registry eagerly, the serializer base lazily).
19-
require_relative "mcp_toolkit/registry"
20-
require_relative "mcp_toolkit/resource"
21-
require_relative "mcp_toolkit/serializer/base"
22-
require_relative "mcp_toolkit/configuration"
23-
24-
# Executors + schema (pure Ruby, no Rails needed to load).
25-
require_relative "mcp_toolkit/list_executor"
26-
require_relative "mcp_toolkit/get_executor"
27-
require_relative "mcp_toolkit/resource_schema"
28-
29-
# Auth: satellite (introspection + authenticator) and authority.
30-
require_relative "mcp_toolkit/auth/introspection"
31-
require_relative "mcp_toolkit/auth/authenticator"
32-
require_relative "mcp_toolkit/auth/authority"
33-
34-
# Server + tools wrap the official `mcp` gem.
35-
require_relative "mcp_toolkit/server"
3619

37-
# Transport (Streamable-HTTP controller concern) + cache-backed session.
38-
require_relative "mcp_toolkit/session"
39-
require_relative "mcp_toolkit/transport/controller_methods"
20+
loader = Zeitwerk::Loader.for_gem
21+
# `version.rb` is loaded manually above; let Zeitwerk ignore it so it doesn't try
22+
# to manage the already-defined constant.
23+
loader.ignore("#{__dir__}/mcp_toolkit/version.rb")
24+
loader.setup
4025

4126
# The toolkit for building account-scoped, read-only MCP servers on top of the
4227
# official `mcp` gem. See README.md for the satellite + authority quickstarts.
Lines changed: 73 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,98 +1,90 @@
11
# frozen_string_literal: true
22

3-
module McpToolkit
4-
module Auth
5-
# SATELLITE side. Resolves the authenticated, scoped context for a tool call:
6-
#
7-
# 1. Introspect the bearer token against the central app (cached).
8-
# 2. Reject if invalid / expired / not scoped to `required_application`.
9-
# 3. Resolve the active central account id, enforcing tenancy:
10-
# - accounts_user token => its single bound `account_id`. A supplied
11-
# selector, if present, MUST match it.
12-
# - user (superuser) token => the selector is REQUIRED and MUST be one of
13-
# the token's `account_ids`.
14-
# 4. Map that central account id to the LOCAL scope root via
15-
# `config.account_resolver` (e.g. Account.find_by(synced_id:)) and return
16-
# it as the tools' `scope_root`.
17-
#
18-
# The account selector mirrors what a gateway forwards: the resolved account id
19-
# arrives as `_meta[config.account_meta_key]`. We also accept an `account_id`
20-
# tool argument and the `config.account_id_header` header as fallbacks.
21-
#
22-
# Extracted from bsa-notifications' `McpServer::Authenticator`, made
23-
# config-driven (account mapping + meta/header keys come from McpToolkit.config).
24-
class Authenticator
25-
Context = Struct.new(:scope_root, :introspection, keyword_init: true)
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 exact `<app>__<action>` scope is enforced separately by Tools::Base
17+
# (#with_account / #with_authentication) via `authorized_for_scope?`; the
18+
# authenticator only validates the token and resolves the tenant.
19+
#
20+
# The account selector mirrors what a gateway forwards: the resolved account id
21+
# arrives as `_meta[config.account_meta_key]`. We also accept an `account_id`
22+
# tool argument and the `config.account_id_header` header as fallbacks.
23+
class McpToolkit::Auth::Authenticator
24+
Context = Struct.new(:scope_root, :introspection, keyword_init: true)
2625

27-
# @param token [String] the plaintext bearer the central app forwarded
28-
# @param meta [Hash] the JSON-RPC `_meta` (string or symbol keys)
29-
# @param arguments [Hash] the tool-call arguments (may carry `account_id`)
30-
# @param header_account_id [Integer,String,nil] the account-id header value
31-
# @param config [McpToolkit::Configuration]
32-
def self.call(token:, meta: {}, arguments: {}, header_account_id: nil, config: McpToolkit.config)
33-
new(token:, meta:, arguments:, header_account_id:, config:).call
34-
end
35-
36-
def initialize(token:, meta:, arguments:, header_account_id:, config: McpToolkit.config)
37-
@token = token
38-
@meta = (meta || {}).transform_keys(&:to_s)
39-
@arguments = (arguments || {}).transform_keys(&:to_s)
40-
@header_account_id = header_account_id
41-
@config = config
42-
end
26+
# @param token [String] the plaintext bearer the central app forwarded
27+
# @param meta [Hash] the JSON-RPC `_meta` (string or symbol keys)
28+
# @param arguments [Hash] the tool-call arguments (may carry `account_id`)
29+
# @param header_account_id [Integer,String,nil] the account-id header value
30+
# @param config [McpToolkit::Configuration]
31+
def self.call(token:, meta: {}, arguments: {}, header_account_id: nil, config: McpToolkit.config)
32+
new(token:, meta:, arguments:, header_account_id:, config:).call
33+
end
4334

44-
def call
45-
introspection = McpToolkit::Auth::Introspection.call(@token, config: @config)
46-
raise McpToolkit::Errors::Unauthorized, "invalid or expired token" unless introspection.valid?
35+
def initialize(token:, meta:, arguments:, header_account_id:, config: McpToolkit.config)
36+
@token = token
37+
@meta = (meta || {}).transform_keys(&:to_s)
38+
@arguments = (arguments || {}).transform_keys(&:to_s)
39+
@header_account_id = header_account_id
40+
@config = config
41+
end
4742

48-
unless introspection.authorized_for_application?(@config.required_application)
49-
raise McpToolkit::Errors::Unauthorized,
50-
"token is not authorized for the #{@config.required_application.inspect} application"
51-
end
43+
def call
44+
introspection = McpToolkit::Auth::Introspection.call(@token, config: @config)
45+
raise McpToolkit::Errors::Unauthorized, "invalid or expired token" unless introspection.valid?
5246

53-
central_account_id = resolve_account_id(introspection)
54-
scope_root = @config.account_resolver.call(central_account_id)
55-
unless scope_root
56-
raise McpToolkit::Errors::Unauthorized, "no local scope found for account_id=#{central_account_id}"
57-
end
47+
central_account_id = resolve_account_id(introspection)
48+
scope_root = @config.account_resolver.call(central_account_id)
49+
unless scope_root
50+
raise McpToolkit::Errors::Unauthorized, "no local scope found for account_id=#{central_account_id}"
51+
end
5852

59-
Context.new(scope_root:, introspection:)
60-
end
53+
Context.new(scope_root:, introspection:)
54+
end
6155

62-
private
56+
private
6357

64-
def resolve_account_id(introspection)
65-
candidate = candidate_account_id
58+
def resolve_account_id(introspection)
59+
candidate = candidate_account_id
6660

67-
if introspection.accounts_user?
68-
bound = introspection.account_id
69-
if candidate.present? && candidate.to_i != bound.to_i
70-
raise McpToolkit::Errors::Unauthorized,
71-
"account_id #{candidate} does not match this token's bound account"
72-
end
61+
if introspection.accounts_user?
62+
bound = introspection.account_id
63+
if candidate.present? && candidate.to_i != bound.to_i
64+
raise McpToolkit::Errors::Unauthorized,
65+
"account_id #{candidate} does not match this token's bound account"
66+
end
7367

74-
return bound
75-
end
68+
return bound
69+
end
7670

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
71+
# superuser / multi-account: selection is mandatory and must be authorized.
72+
if candidate.blank?
73+
raise McpToolkit::Errors::Unauthorized,
74+
"this token spans multiple accounts; an account must be selected " \
75+
"via _meta[\"#{@config.account_meta_key}\"] (or the account_id argument)"
76+
end
8377

84-
unless introspection.authorized_account_ids.include?(candidate.to_i)
85-
raise McpToolkit::Errors::Unauthorized, "account_id #{candidate} is not authorized for this token"
86-
end
78+
unless introspection.authorized_account_ids.include?(candidate.to_i)
79+
raise McpToolkit::Errors::Unauthorized, "account_id #{candidate} is not authorized for this token"
80+
end
8781

88-
candidate.to_i
89-
end
82+
candidate.to_i
83+
end
9084

91-
def candidate_account_id
92-
@meta[@config.account_meta_key].presence ||
93-
@arguments["account_id"].presence ||
94-
@header_account_id.presence
95-
end
96-
end
85+
def candidate_account_id
86+
@meta[@config.account_meta_key].presence ||
87+
@arguments["account_id"].presence ||
88+
@header_account_id.presence
9789
end
9890
end

lib/mcp_toolkit/auth/authority.rb

Lines changed: 66 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,77 @@
11
# frozen_string_literal: true
22

3-
module McpToolkit
4-
module Auth
5-
# AUTHORITY side. The helpers the central app uses to (a) authenticate a
6-
# plaintext bearer token against its local token store and (b) answer the
7-
# introspection request satellites send.
8-
#
9-
# Both are thin and config-driven: the actual token lookup is the app's
10-
# `config.token_authenticator` callable (its `McpToken.authenticate`
11-
# equivalent), and the introspection payload is derived from the duck-typed
12-
# token object that callable returns.
13-
#
14-
# ## Token object contract
15-
#
16-
# `config.token_authenticator.call(plaintext)` must return nil (no/invalid
17-
# token) or an object responding to:
18-
#
19-
# #kind -> :accounts_user | :user (or string equivalents)
20-
# #account_id -> the single bound account id for an accounts_user token,
21-
# else nil
22-
# #account_ids -> Array of authorized account ids
23-
# #expires_at -> a Time/DateTime responding to #iso8601, or nil
24-
# #application_keys -> Array of application keys the token is scoped to ([] = unrestricted)
25-
# #scopes -> Array of OAuth-style `<app>__<action>` scopes ([] = unrestricted).
26-
# Optional; absent => emitted as []. Replaces application_keys
27-
# as the authorization source on the satellite side.
28-
#
29-
# Optionally `#touch_last_used!` (called after a successful authenticate if
30-
# present). A typical app token model (e.g. `McpToken`) satisfies this.
31-
module Authority
32-
module_function
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 ([] = unrestricted).
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+
module_function
3329

34-
# Authenticate a plaintext bearer locally. Returns the token object or nil.
35-
# Calls `touch_last_used!` on the token if it responds to it (throttled
36-
# last-used tracking is the token model's concern, not ours).
37-
def authenticate(plaintext, config: McpToolkit.config)
38-
authenticator = config.token_authenticator
39-
if authenticator.nil?
40-
raise McpToolkit::Errors::ConfigurationError,
41-
"token_authenticator is not configured; required for the :authority role"
42-
end
30+
# Authenticate a plaintext bearer locally. Returns the token object or nil.
31+
# Calls `touch_last_used!` on the token if it responds to it (throttled
32+
# last-used tracking is the token model's concern, not ours).
33+
def authenticate(plaintext, config: McpToolkit.config)
34+
authenticator = config.token_authenticator
35+
if authenticator.nil?
36+
raise McpToolkit::Errors::ConfigurationError,
37+
"token_authenticator is not configured; required for the :authority role"
38+
end
4339

44-
token = authenticator.call(plaintext)
45-
return nil unless token
40+
token = authenticator.call(plaintext)
41+
return nil unless token
4642

47-
token.touch_last_used! if token.respond_to?(:touch_last_used!)
48-
token
49-
end
43+
token.touch_last_used! if token.respond_to?(:touch_last_used!)
44+
token
45+
end
5046

51-
# Build the introspection response payload for a token object. This is the
52-
# JSON the authority's `/mcp/tokens/introspect` endpoint renders, and the
53-
# exact contract Auth::Introspection (the satellite) parses.
54-
#
55-
# @param token [#kind, #account_id, #account_ids, #expires_at, #application_keys]
56-
# @return [Hash]
57-
def introspection_payload(token)
58-
account_ids = Array(token.account_ids)
59-
{
60-
valid: true,
61-
kind: token.kind.to_s,
62-
account_id: account_id_for(token, account_ids),
63-
account_ids:,
64-
expires_at: token.expires_at&.iso8601,
65-
applications: Array(token.application_keys),
66-
scopes: (token.respond_to?(:scopes) ? Array(token.scopes) : [])
67-
}
68-
end
47+
# Build the introspection response payload for a token object. This is the
48+
# JSON the authority's `/mcp/tokens/introspect` endpoint renders, and the
49+
# exact contract Auth::Introspection (the satellite) parses.
50+
#
51+
# @param token [#kind, #account_id, #account_ids, #expires_at, #scopes]
52+
# @return [Hash]
53+
def introspection_payload(token)
54+
account_ids = Array(token.account_ids)
55+
{
56+
valid: true,
57+
kind: token.kind.to_s,
58+
account_id: account_id_for(token, account_ids),
59+
account_ids:,
60+
expires_at: token.expires_at&.iso8601,
61+
scopes: Array(token.scopes)
62+
}
63+
end
6964

70-
# The payload returned for a missing/invalid token. Render with HTTP 401.
71-
def invalid_payload
72-
{ valid: false }
73-
end
65+
# The payload returned for a missing/invalid token. Render with HTTP 401.
66+
def invalid_payload
67+
{ valid: false }
68+
end
7469

75-
# account_id is the single bound account for an accounts_user token, else nil
76-
# (a superuser/multi-account token advertises its set via account_ids).
77-
def account_id_for(token, account_ids)
78-
return token.account_id if token.respond_to?(:account_id) && token.account_id
70+
# account_id is the single bound account for an accounts_user token, else nil
71+
# (a superuser/multi-account token advertises its set via account_ids).
72+
def account_id_for(token, account_ids)
73+
return token.account_id if token.respond_to?(:account_id) && token.account_id
7974

80-
token.kind.to_s == "accounts_user" ? account_ids.first : nil
81-
end
82-
end
75+
token.kind.to_s == "accounts_user" ? account_ids.first : nil
8376
end
8477
end

0 commit comments

Comments
 (0)