administrate-mcp 0.1.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/CHANGELOG.md +61 -0
- data/LICENSE.txt +21 -0
- data/README.md +267 -0
- data/app/controllers/administrate/mcp/json_rpc_controller.rb +83 -0
- data/app/controllers/administrate/mcp/o_auth_controller.rb +199 -0
- data/app/lib/administrate/mcp/actions.rb +170 -0
- data/app/lib/administrate/mcp/admin_dashboard_tool.rb +114 -0
- data/app/lib/administrate/mcp/authentication.rb +146 -0
- data/app/lib/administrate/mcp/base_tool.rb +93 -0
- data/app/lib/administrate/mcp/clean_old_feedbacks.rb +26 -0
- data/app/lib/administrate/mcp/dashboard_registry.rb +102 -0
- data/app/lib/administrate/mcp/fast_search.rb +47 -0
- data/app/lib/administrate/mcp/field_serializer.rb +284 -0
- data/app/lib/administrate/mcp/o_auth_service.rb +103 -0
- data/app/lib/administrate/mcp/report_improvement.rb +58 -0
- data/app/lib/administrate/mcp/server_builder.rb +70 -0
- data/app/lib/administrate/mcp/tools/admin_resource_list.rb +194 -0
- data/app/lib/administrate/mcp/tools/admin_resource_list_resources.rb +107 -0
- data/app/lib/administrate/mcp/tools/admin_resource_show.rb +130 -0
- data/app/lib/administrate/mcp/tools/report_improvement.rb +43 -0
- data/app/lib/administrate/mcp/tools/sidekiq_retries.rb +50 -0
- data/app/lib/administrate/mcp/tools/sidekiq_stats.rb +75 -0
- data/app/models/administrate/mcp/api_key.rb +59 -0
- data/app/models/administrate/mcp/application_record.rb +23 -0
- data/app/models/administrate/mcp/feedback.rb +20 -0
- data/app/models/administrate/mcp/o_auth_access_grant.rb +76 -0
- data/app/models/administrate/mcp/o_auth_access_token.rb +84 -0
- data/app/models/administrate/mcp/o_auth_application.rb +64 -0
- data/app/views/administrate/mcp/o_auth/authorize.html.erb +63 -0
- data/config/routes.rb +6 -0
- data/db/migrate/20260101000001_create_administrate_model_context_protocol_api_keys.rb +20 -0
- data/db/migrate/20260101000002_create_administrate_model_context_protocol_feedbacks.rb +19 -0
- data/db/migrate/20260101000003_create_administrate_model_context_protocol_authorization_tables.rb +52 -0
- data/docs/admin-integration.md +56 -0
- data/docs/authentication.md +116 -0
- data/docs/configuration.md +220 -0
- data/docs/dashboards.md +50 -0
- data/docs/development.md +19 -0
- data/docs/oauth.md +54 -0
- data/docs/routes.md +30 -0
- data/lib/administrate/mcp/authorization/base.rb +45 -0
- data/lib/administrate/mcp/authorization/permissive.rb +13 -0
- data/lib/administrate/mcp/authorization/pundit.rb +32 -0
- data/lib/administrate/mcp/cloudflare_access.rb +140 -0
- data/lib/administrate/mcp/configuration.rb +156 -0
- data/lib/administrate/mcp/dashboard_extension.rb +25 -0
- data/lib/administrate/mcp/engine.rb +21 -0
- data/lib/administrate/mcp/errors.rb +21 -0
- data/lib/administrate/mcp/loopback_uri.rb +18 -0
- data/lib/administrate/mcp/rack_attack.rb +39 -0
- data/lib/administrate/mcp/routes.rb +48 -0
- data/lib/administrate/mcp/version.rb +7 -0
- data/lib/administrate/mcp.rb +35 -0
- data/lib/administrate-mcp.rb +3 -0
- metadata +160 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# Configuration
|
|
2
|
+
|
|
3
|
+
[Back to README](../README.md)
|
|
4
|
+
|
|
5
|
+
Configure the engine in an initializer, `config/initializers/administrate_mcp.rb`. It must run
|
|
6
|
+
before the engine's models load, because the `admin` association reads `admin_class_name`.
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
Administrate::MCP.configure do |c|
|
|
10
|
+
c.server_name = 'acme_admin'
|
|
11
|
+
c.server_version = '1.0.0'
|
|
12
|
+
|
|
13
|
+
c.admin_class_name = 'Administrator'
|
|
14
|
+
c.current_admin = ->(controller) { controller.send(:warden)&.authenticate(scope: :administrator) }
|
|
15
|
+
c.admin_active = ->(admin) { admin.admin? && admin.anonymized_at.nil? }
|
|
16
|
+
c.sign_in = lambda do |controller|
|
|
17
|
+
controller.send(:store_location_for, :administrator, controller.request.fullpath)
|
|
18
|
+
controller.redirect_to(controller.main_app.new_administrator_session_path)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# These procs are also called without a request, when the engine builds a record URL outside a
|
|
22
|
+
# request cycle, so guard the dereference.
|
|
23
|
+
c.issuer = ->(request) { request&.subdomain == 'admin-mcp' ? request.base_url : 'https://admin-mcp.acme.com' }
|
|
24
|
+
c.admin_origin = ->(request) { request&.subdomain == 'admin' ? request.base_url : 'https://admin.acme.com' }
|
|
25
|
+
c.admin_url_options = { subdomain: 'admin' }
|
|
26
|
+
|
|
27
|
+
c.authorization = Administrate::MCP::Authorization::Pundit.new
|
|
28
|
+
c.default_required_roles = [:full_access]
|
|
29
|
+
|
|
30
|
+
c.tool_paths = [Rails.root.join('app/mcp_tools')]
|
|
31
|
+
|
|
32
|
+
c.instrument = lambda do |tool_name:, admin:, &block|
|
|
33
|
+
Datadog::Tracing.trace('call', resource: tool_name, service: 'mcp_tool') { block.call }
|
|
34
|
+
end
|
|
35
|
+
c.on_tool_call = lambda do |tool_name:, admin:, arguments:, scopes:|
|
|
36
|
+
Admin::AuditOperation.call!(administrator_id: admin.id, request_method: 'POST',
|
|
37
|
+
request_url: "mcp://tools/#{tool_name}", params: arguments)
|
|
38
|
+
end
|
|
39
|
+
c.on_feedback = ->(feedback) { SlackNotifier.notify(notification_type: :mcp_feedback, blocks: blocks_for(feedback)) }
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## The full configuration object
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
class Configuration
|
|
47
|
+
SKIPPED_FIELD_CLASSES = ['Administrate::Field::Password'].freeze
|
|
48
|
+
|
|
49
|
+
HAS_MANY_FIELD_CLASSES = ['Administrate::Field::HasMany'].freeze
|
|
50
|
+
|
|
51
|
+
FIELD_SERIALIZERS = {
|
|
52
|
+
'Administrate::Field::HasMany' => :has_many,
|
|
53
|
+
'Administrate::Field::Polymorphic' => :polymorphic,
|
|
54
|
+
'Administrate::Field::BelongsTo' => :belongs_to,
|
|
55
|
+
'Administrate::Field::HasOne' => :belongs_to,
|
|
56
|
+
'Administrate::Field::DateTime' => :datetime,
|
|
57
|
+
'Administrate::Field::Date' => :datetime,
|
|
58
|
+
'Administrate::Field::Time' => :datetime,
|
|
59
|
+
'Administrate::Field::Enum' => :enum,
|
|
60
|
+
'Administrate::Field::Select' => :enum,
|
|
61
|
+
'Administrate::Field::Shrine' => :attachment,
|
|
62
|
+
'Administrate::Field::String' => :scalar,
|
|
63
|
+
'Administrate::Field::Text' => :scalar,
|
|
64
|
+
'Administrate::Field::Email' => :scalar,
|
|
65
|
+
'Administrate::Field::Url' => :scalar,
|
|
66
|
+
'Administrate::Field::Number' => :scalar,
|
|
67
|
+
'Administrate::Field::Boolean' => :scalar
|
|
68
|
+
}.freeze
|
|
69
|
+
|
|
70
|
+
attr_accessor :server_name,
|
|
71
|
+
:server_version,
|
|
72
|
+
:issuer,
|
|
73
|
+
:admin_origin,
|
|
74
|
+
:current_admin,
|
|
75
|
+
:admin_active,
|
|
76
|
+
:identity_fallback,
|
|
77
|
+
:oauth,
|
|
78
|
+
:sign_in,
|
|
79
|
+
:admin_class_name,
|
|
80
|
+
:authorization,
|
|
81
|
+
:default_required_roles,
|
|
82
|
+
:tool_paths,
|
|
83
|
+
:dashboard_paths,
|
|
84
|
+
:instrument,
|
|
85
|
+
:on_tool_call,
|
|
86
|
+
:on_feedback,
|
|
87
|
+
:on_error,
|
|
88
|
+
:allow_localhost_redirects,
|
|
89
|
+
:default_client_name,
|
|
90
|
+
:sidekiq_stats_provider,
|
|
91
|
+
:admin_route_namespace,
|
|
92
|
+
:admin_url_options,
|
|
93
|
+
:skipped_field_classes,
|
|
94
|
+
:has_many_field_classes,
|
|
95
|
+
:field_serializers
|
|
96
|
+
|
|
97
|
+
attr_reader :api_key_token_prefix
|
|
98
|
+
|
|
99
|
+
def assign_identity
|
|
100
|
+
@server_name = 'administrate_mcp'
|
|
101
|
+
@server_version = VERSION
|
|
102
|
+
@issuer = nil
|
|
103
|
+
@admin_origin = nil
|
|
104
|
+
@current_admin = ->(_controller) {}
|
|
105
|
+
@admin_active = ->(_admin) { true }
|
|
106
|
+
@identity_fallback = ->(_request) {}
|
|
107
|
+
@oauth = true
|
|
108
|
+
@sign_in = nil
|
|
109
|
+
@admin_class_name = 'Administrator'
|
|
110
|
+
@authorization = default_authorization
|
|
111
|
+
@default_required_roles = []
|
|
112
|
+
@tool_paths = []
|
|
113
|
+
@dashboard_paths = nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def assign_hooks
|
|
117
|
+
@instrument = ->(tool_name:, admin:, &block) { block.call }
|
|
118
|
+
@on_tool_call = ->(tool_name:, admin:, arguments:, scopes:) {}
|
|
119
|
+
@on_feedback = ->(feedback) {}
|
|
120
|
+
@on_error = ->(exception) {}
|
|
121
|
+
@allow_localhost_redirects = true
|
|
122
|
+
@api_key_token_prefix = 'amcp_'
|
|
123
|
+
@default_client_name = 'MCP Client'
|
|
124
|
+
@sidekiq_stats_provider = nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def assign_fields
|
|
128
|
+
@admin_route_namespace = :admin
|
|
129
|
+
@admin_url_options = {}
|
|
130
|
+
@skipped_field_classes = SKIPPED_FIELD_CLASSES.dup
|
|
131
|
+
@has_many_field_classes = HAS_MANY_FIELD_CLASSES.dup
|
|
132
|
+
@field_serializers = FIELD_SERIALIZERS.dup
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def api_key_token_prefix=(prefix); end # raises ArgumentError on a blank prefix
|
|
136
|
+
|
|
137
|
+
def register_field(class_name, as: nil, &serializer); end
|
|
138
|
+
def skip_field(*class_names); end
|
|
139
|
+
def register_has_many_field(*class_names); end
|
|
140
|
+
|
|
141
|
+
def issuer_for(request = nil); end
|
|
142
|
+
def admin_origin_for(request = nil); end
|
|
143
|
+
def dashboard_directories; end
|
|
144
|
+
end
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
| Entry | Default | What it does |
|
|
148
|
+
| --------------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
149
|
+
| `server_name` | `'administrate_mcp'` | Name reported in the MCP handshake |
|
|
150
|
+
| `server_version` | the gem version | Version reported in the handshake |
|
|
151
|
+
| `issuer` | `nil` | MCP origin. String or a proc taking the request |
|
|
152
|
+
| `admin_origin` | `nil` | Admin origin, where the consent screen lives. String or proc |
|
|
153
|
+
| `current_admin` | returns `nil` | Proc taking the OAuth controller, returning the signed-in admin |
|
|
154
|
+
| `admin_active` | `true` | Proc taking the authenticated admin. Return false and the call is refused with 401 `inactive_admin`, so a credential does not outlive the person's admin status |
|
|
155
|
+
| `identity_fallback` | returns `nil` | Proc taking the request, returning an `Authentication::Identity` or nil, consulted when no credential the engine issued matched |
|
|
156
|
+
| `oauth` | `true` | Whether the engine serves its own OAuth 2.1 server. False draws no OAuth routes and never looks an access token up |
|
|
157
|
+
| `sign_in` | `nil` (renders 401) | Proc taking the OAuth controller, sending an anonymous visitor to sign in |
|
|
158
|
+
| `admin_class_name` | `'Administrator'` | Class the `admin_id` column points at |
|
|
159
|
+
| `authorization` | Pundit if defined, else Permissive | Adapter: `authorize!`, `authorized?`, `authorize_roles!` |
|
|
160
|
+
| `default_required_roles` | `[]` | Roles a tool requires unless it declares its own |
|
|
161
|
+
| `tool_paths` | `[]` | Directories scanned for extra `BaseTool` subclasses |
|
|
162
|
+
| `dashboard_paths` | `app/dashboards` | Where dashboards are found |
|
|
163
|
+
| `instrument` | yields | Around hook `(tool_name:, admin:, &block)` |
|
|
164
|
+
| `on_tool_call` | no-op | Audit hook `(tool_name:, admin:, arguments:, scopes:)`, after the permission checks |
|
|
165
|
+
| `on_feedback` | no-op | Called with each new `Feedback` record |
|
|
166
|
+
| `on_error` | no-op | Called with an exception the engine swallowed, so you can report it |
|
|
167
|
+
| `allow_localhost_redirects` | `true` | Whether loopback OAuth redirect URIs are accepted. Inert when `oauth` is false |
|
|
168
|
+
| `api_key_token_prefix` | `'amcp_'` | Prefix that marks a bearer token as an API key. Cannot be blank |
|
|
169
|
+
| `default_client_name` | `'MCP Client'` | Name given to a dynamically registered client that sends no `client_name`. Inert when `oauth` is false |
|
|
170
|
+
| `sidekiq_stats_provider` | `nil` | Object answering `counts`, `total_counts`, `queues`, `stats_cleared_at`; a class name String or a Proc is resolved lazily so autoloaded providers can be named in an initializer |
|
|
171
|
+
| `admin_route_namespace` | `:admin` | Namespace record URLs are built from |
|
|
172
|
+
| `admin_url_options` | `{}` | Options passed to `polymorphic_url`; when no `:host` is given, host, protocol and port are taken from `admin_origin` |
|
|
173
|
+
| `skipped_field_classes` | Password | Field class names never serialized |
|
|
174
|
+
| `has_many_field_classes` | HasMany | Field class names treated as expandable collections |
|
|
175
|
+
| `field_serializers` | see above | Field class name to serializer |
|
|
176
|
+
|
|
177
|
+
## Authorization adapters
|
|
178
|
+
|
|
179
|
+
`Authorization::Permissive` grants every action to every authenticated admin. `Authorization::Pundit`
|
|
180
|
+
runs the policy for the resource exactly as the Administrate UI does: `index?` to list, `show?` to
|
|
181
|
+
read, and the action's own predicate for a write action.
|
|
182
|
+
|
|
183
|
+
Both inherit `authorize_roles!` from `Authorization::Base`: a tool's `requires_roles` (or
|
|
184
|
+
`default_required_roles`) is checked with `admin.can_access?(*roles)`. An admin class that does not
|
|
185
|
+
answer `can_access?` raises `Administrate::MCP::ConfigurationError` rather than letting the call
|
|
186
|
+
through. Clear `default_required_roles`, give the class the method, or override `authorize_roles!`.
|
|
187
|
+
|
|
188
|
+
`report_mcp_improvement` requires no role: reporting that a tool description or result is wrong is
|
|
189
|
+
not a privileged action, so it stays open to any authenticated admin regardless of
|
|
190
|
+
`default_required_roles`.
|
|
191
|
+
|
|
192
|
+
Write your own by answering three methods:
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
class MyAuthorization < Administrate::MCP::Authorization::Base
|
|
196
|
+
def authorize!(admin, record_or_class, action); end # raise Administrate::MCP::UnauthorizedError
|
|
197
|
+
def authorized?(admin, record_or_class, action); end # true / false
|
|
198
|
+
def authorize_roles!(admin, roles); end # optional, inherited
|
|
199
|
+
end
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Serializing your own field classes
|
|
203
|
+
|
|
204
|
+
The engine matches field classes by name, walking the field's ancestors, so it never references a
|
|
205
|
+
class you own:
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
Administrate::MCP.configure do |c|
|
|
209
|
+
c.register_field('MonetaryAmountsField') { |source| source.value&.serialize }
|
|
210
|
+
c.register_field('AnyAttachmentField', as: :attachment)
|
|
211
|
+
c.register_has_many_field('HasManyNonAssociationField')
|
|
212
|
+
c.skip_field('GridField', 'HasOneNonAssociationField')
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
A field class that answers `mcp_value(record, attr_name)` and is not registered is asked for its own
|
|
217
|
+
value. An unregistered field class with no `mcp_value` falls back to its raw value.
|
|
218
|
+
|
|
219
|
+
`Field::Text` and `Field::Url`, along with every other field class registered as `:scalar`,
|
|
220
|
+
serialize a nil value as `null` rather than an empty string.
|
data/docs/dashboards.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Dashboard declarations
|
|
2
|
+
|
|
3
|
+
[Back to README](../README.md)
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
class OrderDashboard < Administrate::BaseDashboard
|
|
7
|
+
MCP_DESCRIPTION = 'Customer orders, including the cancelled ones the UI hides by default.'
|
|
8
|
+
MCP_BASE_SCOPE = -> { Order.unscope(where: :state) }
|
|
9
|
+
|
|
10
|
+
COLLECTION_FILTERS = {
|
|
11
|
+
unpaid: ->(scope) { scope.where(paid_at: nil) },
|
|
12
|
+
for_country: ->(scope, code) { scope.where(country: code) }
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
mcp_action :refund,
|
|
16
|
+
description: 'Refund an order.',
|
|
17
|
+
params: { reason: { type: 'string', description: 'Why the order is being refunded.' } } do |record:, admin:, params:|
|
|
18
|
+
Orders::Refund.call!(order: record, admin:, reason: params[:reason])
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Every `MCP_` constant and `COLLECTION_FILTERS` is read off the dashboard itself, never inherited, so
|
|
24
|
+
a constant of the same name defined at the top level of your application, or on a shared dashboard
|
|
25
|
+
base class, is not applied to every resource. A subclass that wants one declares it.
|
|
26
|
+
|
|
27
|
+
- `MCP_DESCRIPTION` tells the client what the resource is.
|
|
28
|
+
- `MCP_SKIPPED_ATTRIBUTES = %i[email]` keeps attributes visible in the admin UI but out of MCP
|
|
29
|
+
reads, listings and field selection.
|
|
30
|
+
- `MCP_EXPOSED = false` leaves the dashboard out of the MCP registry entirely.
|
|
31
|
+
- Namespaced models work through the dashboard's own `self.model`, as in Administrate.
|
|
32
|
+
- `MCP_BASE_SCOPE` replaces the model's default scope for MCP reads, mirroring an admin controller's
|
|
33
|
+
`scoped_resource`.
|
|
34
|
+
- `COLLECTION_FILTERS` are published as filters; a one-argument lambda is a boolean filter, a
|
|
35
|
+
two-argument one takes a value. Foreign key columns are filterable without being declared.
|
|
36
|
+
- `mcp_action` publishes one tool per declaration. It requires the OAuth `write` scope, and the
|
|
37
|
+
authorization predicate (`refund?` by default, or `predicate:`) on the loaded record. The block
|
|
38
|
+
receives `record:`, `admin:` and `params:`; a result answering `success? == false` is reported as
|
|
39
|
+
an error.
|
|
40
|
+
|
|
41
|
+
## Search
|
|
42
|
+
|
|
43
|
+
The `admin_resource_list` tool's `query` argument matches exactly by default, so searching for an id
|
|
44
|
+
or a slug does not also match every row that merely contains it. `*` is the only wildcard. Every
|
|
45
|
+
comparison casts the column to text first, so a search term matched against a uuid `id` column
|
|
46
|
+
returns no rows instead of raising an error.
|
|
47
|
+
|
|
48
|
+
Search is implemented by `FastSearch`, a subclass of `Administrate::Search` that calls methods that
|
|
49
|
+
class treats as internal (`search_attributes`, `searchable_fields`, `query_table_name`,
|
|
50
|
+
`column_to_query`). That is why the gemspec pins `administrate` below 2.0.
|
data/docs/development.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Development
|
|
2
|
+
|
|
3
|
+
[Back to README](../README.md)
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
bundle install
|
|
7
|
+
bundle exec rspec
|
|
8
|
+
bundle exec rubocop
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
You need a reachable PostgreSQL server; the test suite creates its own database on first run (the
|
|
12
|
+
spec helper catches `ActiveRecord::NoDatabaseError` and calls `ActiveRecord::Tasks::DatabaseTasks.create`),
|
|
13
|
+
so no separate `createdb` step is needed. Point the specs at another Postgres with
|
|
14
|
+
`ADMINISTRATE_MCP_DB_HOST`, `_PORT`, `_USER`, `_PASSWORD` and `_NAME`.
|
|
15
|
+
|
|
16
|
+
The dummy application under `spec/dummy` has an `Admin`, a `Widget` (with `MCP_BASE_SCOPE` and
|
|
17
|
+
collection filters) and a `Gadget` (with a `belongs_to` and an `mcp_action`).
|
|
18
|
+
|
|
19
|
+
See [CONTRIBUTING.md](../CONTRIBUTING.md) for how to propose a change.
|
data/docs/oauth.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# OAuth
|
|
2
|
+
|
|
3
|
+
[Back to README](../README.md)
|
|
4
|
+
|
|
5
|
+
The engine ships a complete OAuth 2.1 authorization server: Dynamic Client Registration, a consent
|
|
6
|
+
screen, PKCE, refresh tokens, and the two discovery documents clients read to find all of it. That is
|
|
7
|
+
the default, and for most hosts it is the whole story.
|
|
8
|
+
|
|
9
|
+
It does not fit a deployment where something in front of the application already does this. Cloudflare
|
|
10
|
+
Access managed OAuth, for instance, serves both discovery documents at its own edge and points
|
|
11
|
+
clients at `<team>.cloudflareaccess.com` for authorization, token, registration and revocation. The
|
|
12
|
+
engine's endpoints are then unreachable, and advertising them only gives clients a second, broken
|
|
13
|
+
answer. Turn the server off:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
Administrate::MCP.configure do |c|
|
|
17
|
+
c.oauth = false
|
|
18
|
+
end
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`Routes.draw_mcp_origin` then draws the JSON-RPC endpoint and nothing else: no `/.well-known/*`, no
|
|
22
|
+
`/oauth/register`, no `/oauth/token`, and `Routes.draw_admin_origin` becomes a no-op, so you can
|
|
23
|
+
leave both calls where they are. API keys keep working. An OAuth token presented while the server is
|
|
24
|
+
off is a bearer token the engine does not recognise, and goes to `identity_fallback` like any other.
|
|
25
|
+
|
|
26
|
+
The models and migrations still ship, and the tables are never queried while `oauth` is false, so a
|
|
27
|
+
host that never ran that migration boots and serves normally. `default_client_name` and
|
|
28
|
+
`allow_localhost_redirects` have nothing to act on and are inert.
|
|
29
|
+
|
|
30
|
+
A verifier for Cloudflare Access managed OAuth ships with the gem, for hosts that run authentication
|
|
31
|
+
at the edge instead of through this server: see
|
|
32
|
+
[Identity fallback](authentication.md#identity-fallback) in the authentication guide.
|
|
33
|
+
|
|
34
|
+
## Dynamic Client Registration
|
|
35
|
+
|
|
36
|
+
Registration accepts `localhost`, `127.0.0.1`, `::1` and any `*.localhost` subdomain as loopback
|
|
37
|
+
hosts, and issues authorization codes to them over plain http rather than requiring https.
|
|
38
|
+
`*.localhost` covers parallel development checkouts running on different subdomains. The match is
|
|
39
|
+
made on the parsed hostname, so a redirect URI on a host such as `localhost.attacker.com` is
|
|
40
|
+
rejected. Set `allow_localhost_redirects = false` to require https for every redirect URI, including
|
|
41
|
+
loopback ones.
|
|
42
|
+
|
|
43
|
+
## Rate limiting
|
|
44
|
+
|
|
45
|
+
The gem does not depend on rack-attack. If you use it, these are the throttles to add, or call
|
|
46
|
+
`Administrate::MCP::RackAttack.throttles(host: 'admin-mcp')` from your own initializer, which
|
|
47
|
+
registers exactly these:
|
|
48
|
+
|
|
49
|
+
| Name | Request | Limit |
|
|
50
|
+
| --------------------------- | -------------------------------------- | -------------------- |
|
|
51
|
+
| `mcp_oauth/register/ip` | `POST /oauth/register` on the MCP host | 5 per minute per IP |
|
|
52
|
+
| `mcp_oauth/register/ip-day` | `POST /oauth/register` on the MCP host | 50 per day per IP |
|
|
53
|
+
| `mcp_oauth/token/ip` | `POST /oauth/token` on the MCP host | 10 per minute per IP |
|
|
54
|
+
| `mcp_oauth/authorize/ip` | `GET /mcp/oauth/authorize` | 20 per minute per IP |
|
data/docs/routes.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Routes
|
|
2
|
+
|
|
3
|
+
[Back to README](../README.md)
|
|
4
|
+
|
|
5
|
+
The consent screen must be served on the admin origin, where the admin's session lives, while the
|
|
6
|
+
JSON-RPC and metadata endpoints live on a dedicated MCP origin. Draw each set inside your own
|
|
7
|
+
constraints:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
Rails.application.routes.draw do
|
|
11
|
+
constraints ->(request) { request.subdomain == 'admin-mcp' } do
|
|
12
|
+
Administrate::MCP::Routes.draw_mcp_origin(self)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
constraints subdomain: 'admin' do
|
|
16
|
+
Administrate::MCP::Routes.draw_admin_origin(self)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`draw_mcp_origin` adds `/.well-known/oauth-protected-resource`,
|
|
22
|
+
`/.well-known/oauth-authorization-server`, `POST /oauth/register`, `POST /oauth/token` and the
|
|
23
|
+
JSON-RPC endpoint at `/`. `draw_admin_origin` adds `GET` and `POST /mcp/oauth/authorize`; pass
|
|
24
|
+
`path:` to move it. With `config.oauth` false the OAuth routes are left out of both. See
|
|
25
|
+
[OAuth](oauth.md) for what that means.
|
|
26
|
+
|
|
27
|
+
Both sets have to sit at the root of their origin. The metadata documents the engine publishes name
|
|
28
|
+
the token, registration and JSON-RPC endpoints as absolute paths, `/oauth/token`, `/oauth/register`,
|
|
29
|
+
`/`, so mounting the engine under a prefix would advertise paths that do not answer. If you serve
|
|
30
|
+
everything on a single origin, call both helpers on that origin rather than mounting the engine.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'administrate/mcp/errors'
|
|
4
|
+
|
|
5
|
+
module Administrate
|
|
6
|
+
module MCP
|
|
7
|
+
module Authorization
|
|
8
|
+
# Shared role gating. Roles are a host concept: an admin class that does not answer
|
|
9
|
+
# `can_access?` cannot be gated on them, and saying so beats letting the call through.
|
|
10
|
+
class Base
|
|
11
|
+
def authorize!(admin, record_or_class, action)
|
|
12
|
+
return if authorized?(admin, record_or_class, action)
|
|
13
|
+
|
|
14
|
+
raise UnauthorizedError, "Not authorized to #{action} #{model_name(record_or_class)}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def authorized?(_admin, _record_or_class, _action)
|
|
18
|
+
true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def authorize_roles!(admin, roles)
|
|
22
|
+
return if roles.blank?
|
|
23
|
+
|
|
24
|
+
unless admin.respond_to?(:can_access?)
|
|
25
|
+
raise ConfigurationError,
|
|
26
|
+
"#{admin.class.name} does not respond to can_access?, so the required roles " \
|
|
27
|
+
"#{roles.join(', ')} cannot be checked. Give the admin class a can_access?(*roles) " \
|
|
28
|
+
'method, clear default_required_roles, or override authorize_roles! on the ' \
|
|
29
|
+
'authorization adapter.'
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
return if admin.can_access?(*roles)
|
|
33
|
+
|
|
34
|
+
raise UnauthorizedError, "Insufficient permissions. Required roles: #{roles.join(', ')}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def model_name(record_or_class)
|
|
40
|
+
record_or_class.is_a?(Class) ? record_or_class.name : record_or_class.class.name
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'administrate/mcp/authorization/base'
|
|
4
|
+
|
|
5
|
+
module Administrate
|
|
6
|
+
module MCP
|
|
7
|
+
module Authorization
|
|
8
|
+
# Grants every action to every authenticated admin. The default when the host has no policies.
|
|
9
|
+
class Permissive < Base
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'administrate/mcp/authorization/base'
|
|
4
|
+
|
|
5
|
+
module Administrate
|
|
6
|
+
module MCP
|
|
7
|
+
module Authorization
|
|
8
|
+
# Runs the host's Pundit policy for the resource, exactly as the Administrate UI does.
|
|
9
|
+
class Pundit < Base
|
|
10
|
+
def authorize!(admin, record_or_class, action)
|
|
11
|
+
policy = policy_for(admin, record_or_class)
|
|
12
|
+
return if policy.public_send(action)
|
|
13
|
+
|
|
14
|
+
raise UnauthorizedError, "Not authorized to #{action} #{model_name(record_or_class)}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def authorized?(admin, record_or_class, action)
|
|
18
|
+
authorize!(admin, record_or_class, action)
|
|
19
|
+
true
|
|
20
|
+
rescue UnauthorizedError, ::Pundit::NotDefinedError
|
|
21
|
+
false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def policy_for(admin, record_or_class)
|
|
27
|
+
::Pundit::PolicyFinder.new(record_or_class).policy!.new(admin, record_or_class)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/core_ext/numeric/time'
|
|
4
|
+
require 'active_support/core_ext/object/blank'
|
|
5
|
+
require 'jwt'
|
|
6
|
+
require 'net/http'
|
|
7
|
+
|
|
8
|
+
module Administrate
|
|
9
|
+
module MCP
|
|
10
|
+
# Verifies the signed assertion Cloudflare Access attaches to every request it lets through.
|
|
11
|
+
#
|
|
12
|
+
# Clients using Access managed OAuth never reach an MCP server's own OAuth flow: Access resolves
|
|
13
|
+
# their token at its own edge and forwards the caller's identity in this header instead. Build
|
|
14
|
+
# one of these and hand it to `config.identity_fallback` — it answers `call(request)`.
|
|
15
|
+
#
|
|
16
|
+
# This ships in `lib` rather than `app` so a host initializer can name the constant: autoloading
|
|
17
|
+
# is not available while initializers run. Nothing outside a method body names a constant the
|
|
18
|
+
# engine autoloads, so `Authentication` still resolves at call time.
|
|
19
|
+
class CloudflareAccess
|
|
20
|
+
ASSERTION_HEADER = 'Cf-Access-Jwt-Assertion'
|
|
21
|
+
ALGORITHM = 'RS256'
|
|
22
|
+
CERTS_PATH = '/cdn-cgi/access/certs'
|
|
23
|
+
KEYS_CACHE_EXPIRY = 1.hour
|
|
24
|
+
AUTH_ERROR_TYPE = 'cloudflare_access'
|
|
25
|
+
|
|
26
|
+
NETWORK_ERRORS = [
|
|
27
|
+
Errno::ECONNREFUSED,
|
|
28
|
+
Errno::ECONNRESET,
|
|
29
|
+
Errno::EHOSTUNREACH,
|
|
30
|
+
Errno::ENETUNREACH,
|
|
31
|
+
IOError,
|
|
32
|
+
Net::OpenTimeout,
|
|
33
|
+
Net::ReadTimeout,
|
|
34
|
+
OpenSSL::SSL::SSLError,
|
|
35
|
+
SocketError,
|
|
36
|
+
Timeout::Error
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
# `team_domain` and `audience` each take a value or something that answers `call`. Hosts build
|
|
40
|
+
# this in an initializer, before the environment that carries those settings is necessarily
|
|
41
|
+
# readable, so a callable is re-read on every request rather than captured at boot.
|
|
42
|
+
def initialize(team_domain:, audience:, find_admin:, scopes_for: ->(_admin) { [] })
|
|
43
|
+
@team_domain = team_domain
|
|
44
|
+
@audience = audience
|
|
45
|
+
@find_admin = find_admin
|
|
46
|
+
@scopes_for = scopes_for
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def team_domain
|
|
50
|
+
resolve(@team_domain)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def audience
|
|
54
|
+
resolve(@audience)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def call(request)
|
|
58
|
+
return nil unless configured?
|
|
59
|
+
|
|
60
|
+
assertion = request.headers[ASSERTION_HEADER]
|
|
61
|
+
return nil if assertion.blank?
|
|
62
|
+
|
|
63
|
+
payload = verify(assertion)
|
|
64
|
+
return nil if payload.nil?
|
|
65
|
+
|
|
66
|
+
admin = find_admin!(payload['email'])
|
|
67
|
+
Authentication::Identity.new(admin:, scopes: @scopes_for.call(admin))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def configured?
|
|
71
|
+
team_domain.present? && audience.present?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def verify(assertion)
|
|
75
|
+
kid = key_id(assertion)
|
|
76
|
+
return nil if kid.blank?
|
|
77
|
+
|
|
78
|
+
key = find_key(kid) || find_key(kid, refresh: true)
|
|
79
|
+
return nil if key.nil?
|
|
80
|
+
|
|
81
|
+
decode(assertion, key)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def decode(assertion, key)
|
|
85
|
+
JWT.decode(
|
|
86
|
+
assertion,
|
|
87
|
+
JWT::JWK.import(key).public_key,
|
|
88
|
+
true,
|
|
89
|
+
algorithms: [ALGORITHM],
|
|
90
|
+
iss: team_domain,
|
|
91
|
+
verify_iss: true,
|
|
92
|
+
aud: audience,
|
|
93
|
+
verify_aud: true
|
|
94
|
+
).first
|
|
95
|
+
rescue JWT::DecodeError, JWT::JWKError
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def key_id(assertion)
|
|
100
|
+
JSON.parse(Base64.urlsafe_decode64(assertion.split('.').first))['kid']
|
|
101
|
+
rescue ArgumentError, JSON::ParserError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def find_key(kid, refresh: false)
|
|
106
|
+
Rails.cache.delete(certs_url) if refresh
|
|
107
|
+
keys = Rails.cache.fetch(certs_url, expires_in: KEYS_CACHE_EXPIRY, skip_nil: true) { fetch_keys }
|
|
108
|
+
keys&.find { |key| key['kid'] == kid }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def fetch_keys
|
|
112
|
+
JSON.parse(Net::HTTP.get(URI.parse(certs_url)))['keys']
|
|
113
|
+
rescue JSON::ParserError, *NETWORK_ERRORS
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def certs_url
|
|
118
|
+
"#{team_domain}#{CERTS_PATH}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def resolve(setting)
|
|
124
|
+
value = setting.respond_to?(:call) ? setting.call : setting
|
|
125
|
+
value.presence
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The identity provider decides how it capitalises an address; the lookup should not care.
|
|
129
|
+
def find_admin!(email)
|
|
130
|
+
admin = @find_admin.call(email.to_s.downcase)
|
|
131
|
+
return admin if admin
|
|
132
|
+
|
|
133
|
+
raise Authentication::ExternalIdentityError.new(
|
|
134
|
+
"No admin account for #{email}",
|
|
135
|
+
auth_error_type: AUTH_ERROR_TYPE
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|