administrate-mcp 0.2.0 → 0.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 68fb06b806eb2dea5dbb0e0a01a865b4a9984b515a358bfd86fab8d0b949b65c
4
- data.tar.gz: ac0a3ef739bf776dff2325bc3ae641e22b694047ffd03d078c944810b2071768
3
+ metadata.gz: f3f38d54e63d5f8d6b88750c73f284d10d5e0500a0a7f47cae82556be264e8fe
4
+ data.tar.gz: 1b1d5fda201d08915bdddd2c6eb92fb97f9698ece7f17cbff1a1d563f280eea9
5
5
  SHA512:
6
- metadata.gz: 1d6204806b8c490362a1e4a56939a3ae588897f069f7c1e21c4ff653eaed486d06cd238e3243d3c3a29edcc2399a7aef25deed80022e598f8b52fc36a7fc7895
7
- data.tar.gz: 2064c7b7ad08e21f32eba4db06ebda81d594e1abf0c731ddf369a901282ce419c923eea77715faa1c25ae44601809e4f3195d55fc539c91e90ae5b2d4107eca1
6
+ metadata.gz: bdc5883ed9f4d6cadab89dd8be6aca8f890e25cfc39d0a40ef6b2b7e9fd6b11af6de5e817d4d8c6047a0bcd9d3ad1a6bd05b4af095bd64c256ad6d65198bbf7e
7
+ data.tar.gz: 21bd4c4e668bad2bd5d9050bc6e86abae5800a605be3604d2683687bb3b52bcf4dae42cc2423f5df2fe09713ac14784cc5188a5d76ba28694fe2f41581fa895e
data/CHANGELOG.md CHANGED
@@ -8,6 +8,56 @@ minor release may change behaviour a host depends on; the entry says so when it
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.3.1] - 2026-10-20
12
+
13
+ ### Security
14
+
15
+ - A `tools/call` argument named `admin` no longer replaces the authenticated admin passed to a
16
+ tool's `execute` and to `mcp_action` blocks
17
+ ([GHSA-vv7c-hhjc-fhq8](https://github.com/sorare/administrate-mcp/security/advisories/GHSA-vv7c-hhjc-fhq8)).
18
+ The call is now refused with a tool error. For built-in and host-defined tools, the refusal comes
19
+ after the scope and role checks, and the `on_tool_call` hook still reports the call under the
20
+ authenticated admin, with the refused argument in `arguments`. The tools generated from
21
+ `mcp_action` refuse it while validating arguments, before the hook runs.
22
+
23
+ ### Changed
24
+
25
+ - **Behaviour change for hosts:** a tool that declares an input property named `admin` now raises
26
+ `ArgumentError` when its class is loaded, and an `mcp_action` param named `admin` or `id` raises
27
+ `ArgumentError` when the dashboard is loaded. Rename the property or param.
28
+ - **Behaviour change for clients:** the tools generated from `mcp_action` declarations now reject
29
+ arguments they do not declare, with an `Invalid arguments` tool error, instead of passing them to
30
+ the block in `params`.
31
+
32
+ ## [0.3.0] - 2026-10-02
33
+
34
+ ### Changed
35
+
36
+ - **Behaviour change for clients:** two failures of `admin_resource_list_resources`,
37
+ `admin_resource_list` and `admin_resource_show` now return HTTP 200 with a tool error
38
+ (`result.isError: true`) instead of HTTP 403 with `X-Auth-Error: forbidden` and JSON-RPC code
39
+ -32003. Clients such as Claude showed that 403 as an authentication failure and dropped the message:
40
+ - An unknown resource name. The message says the name is not registered and lists up to five
41
+ close registered names, taken only from the resources the caller can read.
42
+ - A registered resource the caller may not read. The message is "Resource `<name>` exists but you
43
+ are not authorized to list it" (or "show it"), followed by the authorization adapter's own
44
+ message when the host wrote one. The old 403 body already told the two cases apart
45
+ (`Unknown resource: …` against `Not authorized to show? <Model>`); the difference is that
46
+ clients now display it.
47
+
48
+ A tool-level refusal, a missing OAuth scope or a required role, is still a 403 for now
49
+ ([#11](https://github.com/sorare/administrate-mcp/issues/11)). The per-resource denial is raised
50
+ as `Administrate::MCP::ResourceForbiddenError`, a subclass of `UnauthorizedError`, so host code
51
+ that rescues `UnauthorizedError` still catches it.
52
+
53
+ ### Fixed
54
+
55
+ - A `subscriptions/listen` request no longer raises `NoMethodError` in the host's Rack stack. The
56
+ `mcp` gem answers it with a streaming SSE body meant to stay open, which the engine's controller
57
+ cannot serve; the transport is now built with `serve_subscriptions_listen: false`, so the method
58
+ is answered with HTTP 404 and JSON-RPC error `-32601` like any unimplemented method, and
59
+ `server/discover` stops advertising the `listChanged` and `subscribe` capability flags.
60
+
11
61
  ## [0.2.0] - 2026-09-22
12
62
 
13
63
  ### Added
@@ -88,6 +138,8 @@ minor release may change behaviour a host depends on; the entry says so when it
88
138
  took the gem from git before 0.1.0 must reinstall its migrations rather than migrate
89
139
  incrementally.
90
140
 
91
- [Unreleased]: https://github.com/sorare/administrate-mcp/compare/v0.2.0...HEAD
141
+ [Unreleased]: https://github.com/sorare/administrate-mcp/compare/v0.3.1...HEAD
142
+ [0.3.1]: https://github.com/sorare/administrate-mcp/compare/v0.3.0...v0.3.1
143
+ [0.3.0]: https://github.com/sorare/administrate-mcp/compare/v0.2.0...v0.3.0
92
144
  [0.2.0]: https://github.com/sorare/administrate-mcp/compare/v0.1.0...v0.2.0
93
145
  [0.1.0]: https://github.com/sorare/administrate-mcp/releases/tag/v0.1.0
@@ -27,6 +27,8 @@ module Administrate
27
27
  keyword_init: true
28
28
  )
29
29
 
30
+ RESERVED_PARAMS = %i[admin id].freeze
31
+
30
32
  class << self
31
33
  # Normalizes the arguments of a `mcp_action` declaration into a spec (everything but the
32
34
  # model, which is inferred from the owning dashboard when definitions are built).
@@ -34,6 +36,7 @@ module Administrate
34
36
  destructive: true, &invoke)
35
37
  raise ArgumentError, "#{name}: a block is required to invoke the action" unless invoke
36
38
 
39
+ reject_reserved_params!(name, params)
37
40
  normalized = normalize_params(params)
38
41
 
39
42
  {
@@ -97,6 +100,11 @@ module Administrate
97
100
 
98
101
  private
99
102
 
103
+ def reject_reserved_params!(name, params)
104
+ reserved = RESERVED_PARAMS & params.keys.map(&:to_sym)
105
+ raise ArgumentError, "#{name}: reserved param name(s): #{reserved.join(', ')}" if reserved.any?
106
+ end
107
+
100
108
  def authorized_for_discovery?(admin, definition)
101
109
  Administrate::MCP.config.authorization.authorized?(admin, definition.model_class, definition.predicate)
102
110
  rescue StandardError
@@ -130,7 +138,7 @@ module Administrate
130
138
  def schema_for(defn, resource)
131
139
  properties = { id: { type: 'string', description: "ID or slug of the #{resource}" } }
132
140
  defn.params.each { |name, spec| properties[name] = spec }
133
- { properties:, required: %w[id] + defn.required.map(&:to_s) }
141
+ { properties:, required: %w[id] + defn.required.map(&:to_s), additionalProperties: false }
134
142
  end
135
143
 
136
144
  # Replaces the role-based default with instance-level authorization on the loaded record.
@@ -1,10 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'did_you_mean'
4
+
3
5
  module Administrate
4
6
  module MCP
5
7
  # Base class for MCP tools that operate on Administrate dashboard resources.
6
8
  class AdminDashboardTool < BaseTool
7
9
  LISTED_VALID_VALUES = 40
10
+ MAX_SUGGESTIONS = 5
11
+ ACTION_VERBS = { index?: 'list', show?: 'show' }.freeze
8
12
 
9
13
  class << self
10
14
  def policy_action
@@ -14,7 +18,7 @@ module Administrate
14
18
  def check_roles!(admin, resource: nil, **)
15
19
  return unless resource && policy_action
16
20
 
17
- entry = find_dashboard_entry!(resource)
21
+ entry = find_dashboard_entry!(admin, resource)
18
22
  authorize_resource!(admin, entry.model_class, policy_action)
19
23
  end
20
24
 
@@ -22,15 +26,57 @@ module Administrate
22
26
  Administrate::MCP.config.authorization.authorized?(admin, model_class, action)
23
27
  end
24
28
 
29
+ # The adapter is the host's and raises a plain UnauthorizedError. Re-raised here so a denial on
30
+ # one resource reaches the caller as a tool error instead of a 403 on the whole response. A
31
+ # message the host wrote is kept, since it may say why; the adapters' generic one adds nothing.
25
32
  def authorize_resource!(admin, model_class, action)
26
33
  Administrate::MCP.config.authorization.authorize!(admin, model_class, action)
34
+ rescue UnauthorizedError => e
35
+ verb = ACTION_VERBS.fetch(action.to_sym) { action.to_s.delete_suffix('?') }
36
+ message = "Resource `#{model_class.name.underscore}` exists but you are not authorized to #{verb} it."
37
+ reason = e.message unless e.message == Authorization::Base.denial_message(action, model_class.name)
38
+ raise ResourceForbiddenError, [message, reason].compact.join(' ')
27
39
  end
28
40
 
29
- def find_dashboard_entry!(resource_name)
41
+ def find_dashboard_entry!(admin, resource_name)
30
42
  entry = DashboardRegistry.find(resource_name)
31
43
  return entry if entry
32
44
 
33
- raise UnauthorizedError, "Unknown resource: #{resource_name}"
45
+ raise InvalidArgumentError, unknown_resource_message(admin, resource_name)
46
+ end
47
+
48
+ def unknown_resource_message(admin, resource_name)
49
+ message = "Unknown resource: #{resource_name}. This name is not registered."
50
+ suggestions = resource_suggestions(admin, resource_name)
51
+ return "#{message} Call admin_resource_list_resources for the catalog." if suggestions.empty?
52
+
53
+ "#{message} Closest registered names: #{suggestions.join(', ')}"
54
+ end
55
+
56
+ # Only names the caller can read are suggested, as in the catalog, so a typo does not reveal
57
+ # hidden resources.
58
+ def resource_suggestions(admin, resource_name)
59
+ suggestion_candidates(resource_name).lazy.select { |name| readable?(admin, name) }.first(MAX_SUGGESTIONS)
60
+ end
61
+
62
+ # Matches on the last path segment too, so a bare "order" finds "shop/order".
63
+ def suggestion_candidates(resource_name)
64
+ key = resource_name.to_s.underscore.singularize
65
+ names = DashboardRegistry.resource_names
66
+ by_segment = names.group_by { |name| name.split('/').last }
67
+ segment = key.split('/').last.to_s
68
+ close_segments = DidYouMean::SpellChecker.new(dictionary: by_segment.keys).correct(segment)
69
+
70
+ (
71
+ by_segment.fetch(segment, []) +
72
+ DidYouMean::SpellChecker.new(dictionary: names).correct(key) +
73
+ close_segments.flat_map { |name| by_segment[name] }
74
+ ).uniq
75
+ end
76
+
77
+ def readable?(admin, resource_name)
78
+ entry = DashboardRegistry.find(resource_name)
79
+ entry && authorized?(admin, entry.model_class, :index?)
34
80
  end
35
81
 
36
82
  def reject_unknown!(kind, given, allowed)
@@ -5,9 +5,17 @@ module Administrate
5
5
  # Base class for all MCP tools — provides role gating, auditing, and response helpers.
6
6
  class BaseTool < ::MCP::Tool
7
7
  UnauthorizedError = Administrate::MCP::UnauthorizedError
8
+ ResourceForbiddenError = Administrate::MCP::ResourceForbiddenError
8
9
  InvalidArgumentError = Administrate::MCP::InvalidArgumentError
9
10
 
11
+ RESERVED_ARGUMENTS = %i[admin].freeze
12
+
10
13
  class << self
14
+ def input_schema(value = NOT_SET)
15
+ reject_reserved_properties!(value) if value.is_a?(Hash)
16
+ super
17
+ end
18
+
11
19
  def requires_roles(*roles)
12
20
  @required_roles = roles.flatten
13
21
  end
@@ -26,11 +34,11 @@ module Administrate
26
34
 
27
35
  def call(server_context:, **args)
28
36
  instrument(server_context) { call_with_context(server_context, **args) }
37
+ rescue ResourceForbiddenError, InvalidArgumentError => e
38
+ error_response(e.message)
29
39
  rescue UnauthorizedError => e
30
40
  (server_context[:authorization_errors] ||= []).push(e.message)
31
41
  error_response(e.message)
32
- rescue InvalidArgumentError => e
33
- error_response(e.message)
34
42
  rescue StandardError => e
35
43
  error_response("Internal error: #{e.message}")
36
44
  end
@@ -46,7 +54,21 @@ module Administrate
46
54
  check_scopes!(server_context)
47
55
  check_roles!(admin, **args)
48
56
  audit!(admin, args, server_context)
49
- execute(admin:, **args)
57
+ reject_reserved_arguments!(args)
58
+ execute(**args, admin:)
59
+ end
60
+
61
+ def reject_reserved_arguments!(args)
62
+ reserved = args.keys & RESERVED_ARGUMENTS
63
+ return if reserved.empty?
64
+
65
+ raise InvalidArgumentError, "Reserved argument(s) cannot be passed: #{reserved.join(', ')}"
66
+ end
67
+
68
+ def reject_reserved_properties!(schema)
69
+ properties = (schema[:properties] || schema['properties'] || {}).keys.map(&:to_sym)
70
+ reserved = properties & RESERVED_ARGUMENTS
71
+ raise ArgumentError, "#{name}: reserved input property name(s): #{reserved.join(', ')}" if reserved.any?
50
72
  end
51
73
 
52
74
  def check_scopes!(server_context)
@@ -35,7 +35,11 @@ module Administrate
35
35
  # The host's own route constraints already restrict which Host values reach the
36
36
  # controller, and this is not loopback-bound. Passing the incoming request's own host
37
37
  # back as `allowed_hosts:` would just validate the Host header against itself.
38
- dns_rebinding_protection: false
38
+ dns_rebinding_protection: false,
39
+ # `subscriptions/listen` answers with a streaming SSE body that is held open. The
40
+ # controller buffers the response and builds a stateless transport per request, so it
41
+ # cannot serve that stream; refusing answers the method as unimplemented (-32601).
42
+ serve_subscriptions_listen: false
39
43
  )
40
44
  server
41
45
  end
@@ -24,10 +24,7 @@ module Administrate
24
24
  description:
25
25
  'Resource type name (e.g., "card", "user", "player"). ' \
26
26
  'Use admin_resource_list_resources with no arguments to see available ones. ' \
27
- 'Many resources are namespaced (e.g. "shop/order") and bare names are not aliased. ' \
28
- 'An unrecognised name currently surfaces as an authorization error, not a "not found" ' \
29
- 'error, so treat an auth failure here as a likely wrong resource name and re-check the ' \
30
- 'catalog rather than assuming the server is unavailable.'
27
+ 'Many resources are namespaced (e.g. "shop/order") and bare names are not aliased.'
31
28
  },
32
29
  query: {
33
30
  type: 'string',
@@ -86,7 +83,6 @@ module Administrate
86
83
  required: %w[resource]
87
84
  )
88
85
 
89
- # rubocop:disable-next Lint/UnusedMethodArgument
90
86
  def self.execute(
91
87
  admin:,
92
88
  resource:,
@@ -99,7 +95,7 @@ module Administrate
99
95
  page: 1,
100
96
  per_page: 10
101
97
  )
102
- entry = find_dashboard_entry!(resource)
98
+ entry = find_dashboard_entry!(admin, resource)
103
99
  dashboard = entry.dashboard_class.new
104
100
  per_page = per_page.to_i.clamp(1, MAX_PER_PAGE)
105
101
  page = [page.to_i, 1].max
@@ -8,7 +8,7 @@ module Administrate
8
8
  class AdminResourceListResources < AdminDashboardTool
9
9
  tool_name 'admin_resource_list_resources'
10
10
  description 'Discover available admin resources. ' \
11
- 'Without a resource param: returns all resource names (lightweight catalog). ' \
11
+ 'Without a resource param: returns the resource names you can read (lightweight catalog). ' \
12
12
  'With a resource param: returns fields and filters for that resource.'
13
13
  annotations read_only_hint: true, destructive_hint: false, open_world_hint: true
14
14
 
@@ -19,10 +19,7 @@ module Administrate
19
19
  description:
20
20
  'Resource name to inspect (optional). When provided, returns fields and filters for that ' \
21
21
  'resource. Must be an exact name from the no-argument catalog — many resources are ' \
22
- 'namespaced (e.g. "shop/order") and bare names are not aliased. An unrecognised name ' \
23
- 'currently surfaces as an authorization error, not a "not found" error, so treat an auth ' \
24
- 'failure here as a likely wrong resource name and re-check the catalog rather than ' \
25
- 'assuming the server is unavailable.'
22
+ 'namespaced (e.g. "shop/order") and bare names are not aliased.'
26
23
  }
27
24
  }
28
25
  )
@@ -49,7 +46,7 @@ module Administrate
49
46
  end
50
47
 
51
48
  def self.detail_response(admin, resource_name)
52
- entry = find_dashboard_entry!(resource_name)
49
+ entry = find_dashboard_entry!(admin, resource_name)
53
50
  authorize_resource!(admin, entry.model_class, :index?)
54
51
 
55
52
  json_response(build_detail(resource_name, entry))
@@ -24,10 +24,7 @@ module Administrate
24
24
  description:
25
25
  'Resource type name (e.g., "card", "user", "player"). ' \
26
26
  'Use admin_resource_list_resources with no arguments to see available ones. ' \
27
- 'Many resources are namespaced (e.g. "shop/order") and bare names are not aliased. ' \
28
- 'An unrecognised name currently surfaces as an authorization error, not a "not found" ' \
29
- 'error, so treat an auth failure here as a likely wrong resource name and re-check the ' \
30
- 'catalog rather than assuming the server is unavailable.'
27
+ 'Many resources are namespaced (e.g. "shop/order") and bare names are not aliased.'
31
28
  },
32
29
  id: {
33
30
  oneOf: [
@@ -75,8 +72,8 @@ module Administrate
75
72
  required: %w[resource id]
76
73
  )
77
74
 
78
- def self.execute(admin:, resource:, id:, fields: nil, expand: nil) # rubocop:disable Lint/UnusedMethodArgument
79
- entry = find_dashboard_entry!(resource)
75
+ def self.execute(admin:, resource:, id:, fields: nil, expand: nil)
76
+ entry = find_dashboard_entry!(admin, resource)
80
77
  dashboard = entry.dashboard_class.new
81
78
  attrs, expand_set =
82
79
  resolve_attributes_and_expansions(dashboard, fields, expand, :show_page_attributes, MAX_EXPAND)
@@ -8,10 +8,16 @@ module Administrate
8
8
  # Shared role gating. Roles are a host concept: an admin class that does not answer
9
9
  # `can_access?` cannot be gated on them, and saying so beats letting the call through.
10
10
  class Base
11
+ # The message the built-in adapters raise. A caller that reformats a denial compares against
12
+ # it to tell the generic message from one the host wrote.
13
+ def self.denial_message(action, model_name)
14
+ "Not authorized to #{action} #{model_name}"
15
+ end
16
+
11
17
  def authorize!(admin, record_or_class, action)
12
18
  return if authorized?(admin, record_or_class, action)
13
19
 
14
- raise UnauthorizedError, "Not authorized to #{action} #{model_name(record_or_class)}"
20
+ raise UnauthorizedError, Base.denial_message(action, model_name(record_or_class))
15
21
  end
16
22
 
17
23
  def authorized?(_admin, _record_or_class, _action)
@@ -11,7 +11,7 @@ module Administrate
11
11
  policy = policy_for(admin, record_or_class)
12
12
  return if policy.public_send(action)
13
13
 
14
- raise UnauthorizedError, "Not authorized to #{action} #{model_name(record_or_class)}"
14
+ raise UnauthorizedError, Base.denial_message(action, model_name(record_or_class))
15
15
  end
16
16
 
17
17
  def authorized?(admin, record_or_class, action)
@@ -9,6 +9,12 @@ module Administrate
9
9
  class UnauthorizedError < Error
10
10
  end
11
11
 
12
+ # Raised when the caller's role may not read one resource. It is still an authorization failure,
13
+ # but it is returned as a tool error rather than a 403: the credentials work for the tool, only
14
+ # this resource is out of reach, and the caller needs the message to tell the two apart.
15
+ class ResourceForbiddenError < UnauthorizedError
16
+ end
17
+
12
18
  # Raised when the host asked for something the configuration cannot deliver.
13
19
  class ConfigurationError < Error
14
20
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Administrate
4
4
  module MCP
5
- VERSION = '0.2.0'
5
+ VERSION = '0.3.1'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: administrate-mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sorare