administrate-mcp 0.2.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 68fb06b806eb2dea5dbb0e0a01a865b4a9984b515a358bfd86fab8d0b949b65c
4
- data.tar.gz: ac0a3ef739bf776dff2325bc3ae641e22b694047ffd03d078c944810b2071768
3
+ metadata.gz: c22f6f3b6febbe8e86b8cb721e30f63e4e9232edbd668a3a311e44ff180b9117
4
+ data.tar.gz: cd60a0dad70e15f8235e1ed6c3c2385b5d8cc56efe2a66e1b84a18b3b5fe4087
5
5
  SHA512:
6
- metadata.gz: 1d6204806b8c490362a1e4a56939a3ae588897f069f7c1e21c4ff653eaed486d06cd238e3243d3c3a29edcc2399a7aef25deed80022e598f8b52fc36a7fc7895
7
- data.tar.gz: 2064c7b7ad08e21f32eba4db06ebda81d594e1abf0c731ddf369a901282ce419c923eea77715faa1c25ae44601809e4f3195d55fc539c91e90ae5b2d4107eca1
6
+ metadata.gz: 65ea611de7b893d7b4aaec605cc91a3bd742187ca3ff2ef340229009daa130a7bc7abb79e1f4bda2d4995670ff0dadfce95252f659366ada2713458a4d70a60c
7
+ data.tar.gz: 23a0677acecdb975efeb760a6f797f9319e0ae95b607be57db1dd5a5be5c1db21141aaa3cfcfbf90625472059b65a3263ae8b10ec0ab00d002414e19d5c67215
data/CHANGELOG.md CHANGED
@@ -8,6 +8,35 @@ 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.0] - 2026-10-02
12
+
13
+ ### Changed
14
+
15
+ - **Behaviour change for clients:** two failures of `admin_resource_list_resources`,
16
+ `admin_resource_list` and `admin_resource_show` now return HTTP 200 with a tool error
17
+ (`result.isError: true`) instead of HTTP 403 with `X-Auth-Error: forbidden` and JSON-RPC code
18
+ -32003. Clients such as Claude showed that 403 as an authentication failure and dropped the message:
19
+ - An unknown resource name. The message says the name is not registered and lists up to five
20
+ close registered names, taken only from the resources the caller can read.
21
+ - A registered resource the caller may not read. The message is "Resource `<name>` exists but you
22
+ are not authorized to list it" (or "show it"), followed by the authorization adapter's own
23
+ message when the host wrote one. The old 403 body already told the two cases apart
24
+ (`Unknown resource: …` against `Not authorized to show? <Model>`); the difference is that
25
+ clients now display it.
26
+
27
+ A tool-level refusal, a missing OAuth scope or a required role, is still a 403 for now
28
+ ([#11](https://github.com/sorare/administrate-mcp/issues/11)). The per-resource denial is raised
29
+ as `Administrate::MCP::ResourceForbiddenError`, a subclass of `UnauthorizedError`, so host code
30
+ that rescues `UnauthorizedError` still catches it.
31
+
32
+ ### Fixed
33
+
34
+ - A `subscriptions/listen` request no longer raises `NoMethodError` in the host's Rack stack. The
35
+ `mcp` gem answers it with a streaming SSE body meant to stay open, which the engine's controller
36
+ cannot serve; the transport is now built with `serve_subscriptions_listen: false`, so the method
37
+ is answered with HTTP 404 and JSON-RPC error `-32601` like any unimplemented method, and
38
+ `server/discover` stops advertising the `listChanged` and `subscribe` capability flags.
39
+
11
40
  ## [0.2.0] - 2026-09-22
12
41
 
13
42
  ### Added
@@ -88,6 +117,7 @@ minor release may change behaviour a host depends on; the entry says so when it
88
117
  took the gem from git before 0.1.0 must reinstall its migrations rather than migrate
89
118
  incrementally.
90
119
 
91
- [Unreleased]: https://github.com/sorare/administrate-mcp/compare/v0.2.0...HEAD
120
+ [Unreleased]: https://github.com/sorare/administrate-mcp/compare/v0.3.0...HEAD
121
+ [0.3.0]: https://github.com/sorare/administrate-mcp/compare/v0.2.0...v0.3.0
92
122
  [0.2.0]: https://github.com/sorare/administrate-mcp/compare/v0.1.0...v0.2.0
93
123
  [0.1.0]: https://github.com/sorare/administrate-mcp/releases/tag/v0.1.0
@@ -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,6 +5,7 @@ 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
 
10
11
  class << self
@@ -26,11 +27,11 @@ module Administrate
26
27
 
27
28
  def call(server_context:, **args)
28
29
  instrument(server_context) { call_with_context(server_context, **args) }
30
+ rescue ResourceForbiddenError, InvalidArgumentError => e
31
+ error_response(e.message)
29
32
  rescue UnauthorizedError => e
30
33
  (server_context[:authorization_errors] ||= []).push(e.message)
31
34
  error_response(e.message)
32
- rescue InvalidArgumentError => e
33
- error_response(e.message)
34
35
  rescue StandardError => e
35
36
  error_response("Internal error: #{e.message}")
36
37
  end
@@ -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.0'
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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sorare