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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +61 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +267 -0
  5. data/app/controllers/administrate/mcp/json_rpc_controller.rb +83 -0
  6. data/app/controllers/administrate/mcp/o_auth_controller.rb +199 -0
  7. data/app/lib/administrate/mcp/actions.rb +170 -0
  8. data/app/lib/administrate/mcp/admin_dashboard_tool.rb +114 -0
  9. data/app/lib/administrate/mcp/authentication.rb +146 -0
  10. data/app/lib/administrate/mcp/base_tool.rb +93 -0
  11. data/app/lib/administrate/mcp/clean_old_feedbacks.rb +26 -0
  12. data/app/lib/administrate/mcp/dashboard_registry.rb +102 -0
  13. data/app/lib/administrate/mcp/fast_search.rb +47 -0
  14. data/app/lib/administrate/mcp/field_serializer.rb +284 -0
  15. data/app/lib/administrate/mcp/o_auth_service.rb +103 -0
  16. data/app/lib/administrate/mcp/report_improvement.rb +58 -0
  17. data/app/lib/administrate/mcp/server_builder.rb +70 -0
  18. data/app/lib/administrate/mcp/tools/admin_resource_list.rb +194 -0
  19. data/app/lib/administrate/mcp/tools/admin_resource_list_resources.rb +107 -0
  20. data/app/lib/administrate/mcp/tools/admin_resource_show.rb +130 -0
  21. data/app/lib/administrate/mcp/tools/report_improvement.rb +43 -0
  22. data/app/lib/administrate/mcp/tools/sidekiq_retries.rb +50 -0
  23. data/app/lib/administrate/mcp/tools/sidekiq_stats.rb +75 -0
  24. data/app/models/administrate/mcp/api_key.rb +59 -0
  25. data/app/models/administrate/mcp/application_record.rb +23 -0
  26. data/app/models/administrate/mcp/feedback.rb +20 -0
  27. data/app/models/administrate/mcp/o_auth_access_grant.rb +76 -0
  28. data/app/models/administrate/mcp/o_auth_access_token.rb +84 -0
  29. data/app/models/administrate/mcp/o_auth_application.rb +64 -0
  30. data/app/views/administrate/mcp/o_auth/authorize.html.erb +63 -0
  31. data/config/routes.rb +6 -0
  32. data/db/migrate/20260101000001_create_administrate_model_context_protocol_api_keys.rb +20 -0
  33. data/db/migrate/20260101000002_create_administrate_model_context_protocol_feedbacks.rb +19 -0
  34. data/db/migrate/20260101000003_create_administrate_model_context_protocol_authorization_tables.rb +52 -0
  35. data/docs/admin-integration.md +56 -0
  36. data/docs/authentication.md +116 -0
  37. data/docs/configuration.md +220 -0
  38. data/docs/dashboards.md +50 -0
  39. data/docs/development.md +19 -0
  40. data/docs/oauth.md +54 -0
  41. data/docs/routes.md +30 -0
  42. data/lib/administrate/mcp/authorization/base.rb +45 -0
  43. data/lib/administrate/mcp/authorization/permissive.rb +13 -0
  44. data/lib/administrate/mcp/authorization/pundit.rb +32 -0
  45. data/lib/administrate/mcp/cloudflare_access.rb +140 -0
  46. data/lib/administrate/mcp/configuration.rb +156 -0
  47. data/lib/administrate/mcp/dashboard_extension.rb +25 -0
  48. data/lib/administrate/mcp/engine.rb +21 -0
  49. data/lib/administrate/mcp/errors.rb +21 -0
  50. data/lib/administrate/mcp/loopback_uri.rb +18 -0
  51. data/lib/administrate/mcp/rack_attack.rb +39 -0
  52. data/lib/administrate/mcp/routes.rb +48 -0
  53. data/lib/administrate/mcp/version.rb +7 -0
  54. data/lib/administrate/mcp.rb +35 -0
  55. data/lib/administrate-mcp.rb +3 -0
  56. metadata +160 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4da1e224450ced4f0f0e209279bf8f07e3e863b8cc54d53deb20071e09c162f5
4
+ data.tar.gz: 7299ecaabb67efa26432087c689d6291d7b839692a97855997f0a5aa94ea8e5c
5
+ SHA512:
6
+ metadata.gz: 52016172bf5d81384da02d845c2a7e491ad88859b414ce20b1732804deeff46f930a4df8e4d73b254721c31c39fe8bf2907cda7e56c32dd7b1728641993025ae
7
+ data.tar.gz: 78e0e4ac8f02ce53a3c4603d5bcb8d95ace6976192fed36519a0df092fa2a0b7bec2ac1fc071e3fcc2862e60f81195377943bd2c7693060c488d5ea4b4548a5a
data/CHANGELOG.md ADDED
@@ -0,0 +1,61 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
6
+ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). While the major version is 0, a
7
+ minor release may change behaviour a host depends on; the entry says so when it does.
8
+
9
+ ## [Unreleased]
10
+
11
+ ## [0.1.0] - 2026-09-22
12
+
13
+ ### Added
14
+
15
+ - Rails engine exposing Administrate dashboards over the Model Context Protocol.
16
+ - Three generic tools built from a host's dashboards: `admin_resource_list_resources`,
17
+ `admin_resource_list`, `admin_resource_show`.
18
+ - `report_mcp_improvement`, a feedback tool so users can report what the server got wrong, stored
19
+ through `Administrate::MCP::Feedback` and cleaned up with `CleanOldFeedbacks`.
20
+ - Two optional Sidekiq tools, `sidekiq_stats` and `sidekiq_retries`, backed by a host-supplied
21
+ `sidekiq_stats_provider`.
22
+ - `mcp_action`, a dashboard macro that publishes a host-declared write action as its own tool.
23
+ - API key authentication: tokens are generated with a configurable prefix
24
+ (`api_key_token_prefix`), stored only as a SHA-256 digest, and default to read-only unless
25
+ granted write access.
26
+ - A complete OAuth 2.1 authorization server: Dynamic Client Registration, a consent screen, PKCE,
27
+ refresh tokens, and the discovery documents clients need to find all of it. Access tokens last a
28
+ week, authorization codes ten minutes, and refreshing revokes the old token.
29
+ - `config.oauth = false` to turn the built-in OAuth server off for hosts where something in front
30
+ of the application, such as Cloudflare Access managed OAuth, already runs one.
31
+ - `config.identity_fallback`, an extension point consulted only when no credential the engine
32
+ issued matches, for hosts that authenticate the caller before the request reaches Rails.
33
+ - `Administrate::MCP::CloudflareAccess`, a verifier for the assertion Cloudflare Access attaches to
34
+ a request, ready to use as an `identity_fallback`.
35
+ - `config.admin_active`, checked on every authenticated call, so a host can revoke every credential
36
+ an admin holds by deactivating the admin in one place.
37
+ - Two authorization adapters, `Authorization::Permissive` and `Authorization::Pundit`, plus
38
+ per-tool role gating through `requires_roles` and `config.default_required_roles`.
39
+ - `FastSearch`, a subclass of `Administrate::Search` that searches exactly by default, with `*` as
40
+ the only wildcard, and casts every compared column to text so a plain word searched against a
41
+ uuid column returns no rows instead of raising.
42
+ - Dashboard-level MCP declarations: `MCP_BASE_SCOPE`, `MCP_SKIPPED_ATTRIBUTES`, `mcp_value` on a
43
+ field, and collection filters.
44
+ - `Administrate::MCP::RackAttack.throttles`, a set of recommended Rack::Attack throttles for the
45
+ OAuth endpoints, for hosts that already depend on rack-attack.
46
+ - Migrations for `administrate_mcp_api_keys`, `administrate_mcp_feedbacks`,
47
+ `administrate_mcp_oauth_applications`, `administrate_mcp_oauth_access_grants` and
48
+ `administrate_mcp_oauth_access_tokens`, all using uuid primary keys, an indexed but
49
+ unconstrained uuid `admin_id`, and array columns for `redirect_uris` and `grant_types`.
50
+
51
+ ### Security
52
+
53
+ - OAuth access tokens, refresh tokens and authorization codes are now stored as SHA-256 digests
54
+ (`token_digest`, `refresh_token_digest`) instead of plaintext, matching how API keys were already
55
+ stored. The plaintext is handed to the caller once, when it is issued, and is not recoverable
56
+ afterwards. This changes the schema of the authorization tables migration, so installations that
57
+ took the gem from git before 0.1.0 must reinstall its migrations rather than migrate
58
+ incrementally.
59
+
60
+ [Unreleased]: https://github.com/sorare/administrate-mcp/compare/v0.1.0...HEAD
61
+ [0.1.0]: https://github.com/sorare/administrate-mcp/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sorare
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,267 @@
1
+ # administrate-mcp
2
+
3
+ A Rails engine that exposes [Administrate](https://github.com/thoughtbot/administrate) dashboards
4
+ over the [Model Context Protocol](https://modelcontextprotocol.io), so an MCP client such as Claude
5
+ can list, show and search your admin data, and run the write actions you opt in, with the same
6
+ permissions the admin UI enforces.
7
+
8
+ [![Gem Version](https://img.shields.io/gem/v/administrate-mcp.svg)](https://rubygems.org/gems/administrate-mcp)
9
+ [![CI](https://github.com/sorare/administrate-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/sorare/administrate-mcp/actions/workflows/ci.yml)
10
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.txt)
11
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D.svg)](administrate-mcp.gemspec)
12
+
13
+ ## Table of contents
14
+
15
+ - [Why](#why)
16
+ - [Features](#features)
17
+ - [Demo](#demo)
18
+ - [Requirements](#requirements)
19
+ - [Installation](#installation)
20
+ - [Quick start](#quick-start)
21
+ - [Configuration](#configuration)
22
+ - [Routes](#routes)
23
+ - [Dashboard declarations](#dashboard-declarations)
24
+ - [Authentication](#authentication)
25
+ - [OAuth](#oauth)
26
+ - [Rate limiting](#rate-limiting)
27
+ - [Admin integration](#admin-integration)
28
+ - [Development](#development)
29
+ - [Security](#security)
30
+ - [Contributing](#contributing)
31
+ - [Changelog](#changelog)
32
+ - [Licence](#licence)
33
+
34
+ ## Why
35
+
36
+ Administrate dashboards are built for a person clicking through a browser. This gem reads the same
37
+ dashboard declarations, the same Pundit policies and the same scoped queries, and publishes them as
38
+ MCP tools, so an LLM client can answer questions about your admin data and, where you allow it, act
39
+ on it, without a second implementation of your authorization rules. Nothing in the engine knows
40
+ about your application; everything host-specific goes through `Administrate::MCP.configure`.
41
+
42
+ ## Features
43
+
44
+ - Three generic tools built from every Administrate dashboard: `admin_resource_list_resources`,
45
+ `admin_resource_list`, `admin_resource_show`.
46
+ - Write actions declared per dashboard with `mcp_action`, each published as its own tool, gated by
47
+ the `write` scope (an API key with write access, or an OAuth token granted it) and the resource's own authorization predicate on the loaded record.
48
+ - Three ways to authenticate a caller: API keys stored as a digest, an external identity provider
49
+ through `identity_fallback` (a Cloudflare Access verifier ships with the gem), and an optional
50
+ built-in OAuth 2.1 server with Dynamic Client Registration, PKCE and refresh tokens, on by default
51
+ and disabled with one setting.
52
+ - Pundit-aware authorization by default: reads run through the same `index?` and `show?` predicates
53
+ as the admin UI, so a resource an admin cannot see in the browser is not exposed over MCP either.
54
+ - Search, filters and field selection built from the dashboard's own declarations, plus foreign key
55
+ filters that need no declaration at all.
56
+ - A feedback tool, `report_mcp_improvement`, so users can report what the server got wrong, and a
57
+ housekeeping method to clean up old feedback records.
58
+ - Optional Sidekiq introspection tools, `sidekiq_stats` and `sidekiq_retries`, wired to a stats
59
+ provider object you supply.
60
+ - No reference to a constant it does not own: field serializers are keyed on class names, dashboards
61
+ opt in per attribute, and every host-specific behaviour is configuration, not a subclass.
62
+
63
+ ## Demo
64
+
65
+ A `tools/list` call against the JSON-RPC endpoint, authenticated with an API key:
66
+
67
+ ```sh
68
+ curl https://admin-mcp.example.com/ \
69
+ -H 'Authorization: Bearer amcp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \
70
+ -H 'Content-Type: application/json' \
71
+ -H 'Accept: application/json, text/event-stream' \
72
+ -d '{"jsonrpc": "2.0", "method": "tools/list", "id": 1}'
73
+ ```
74
+
75
+ returns the generic tools built from your dashboards, plus any `mcp_action` you declared, among
76
+ them:
77
+
78
+ ```json
79
+ {
80
+ "result": {
81
+ "tools": [
82
+ { "name": "admin_resource_list_resources" },
83
+ { "name": "admin_resource_list" },
84
+ { "name": "admin_resource_show" },
85
+ { "name": "report_mcp_improvement" }
86
+ ]
87
+ }
88
+ }
89
+ ```
90
+
91
+ A `tools/call` against `admin_resource_list`, restricted to three fields:
92
+
93
+ ```json
94
+ {
95
+ "jsonrpc": "2.0",
96
+ "method": "tools/call",
97
+ "id": 2,
98
+ "params": {
99
+ "name": "admin_resource_list",
100
+ "arguments": { "resource": "widget", "fields": ["id", "name", "status"] }
101
+ }
102
+ }
103
+ ```
104
+
105
+ returns columns, rows and pagination metadata built from the dashboard's `COLLECTION_ATTRIBUTES`:
106
+
107
+ ```json
108
+ {
109
+ "columns": ["url", "id", "name", "status"],
110
+ "rows": [
111
+ [
112
+ "https://admin.example.com/admin/widgets/1",
113
+ "1",
114
+ "Turbo encabulator",
115
+ "published"
116
+ ],
117
+ [
118
+ "https://admin.example.com/admin/widgets/2",
119
+ "2",
120
+ "Flux capacitor",
121
+ "draft"
122
+ ]
123
+ ],
124
+ "meta": { "page": 1, "per_page": 10, "total_count": 2, "total_pages": 1 }
125
+ }
126
+ ```
127
+
128
+ ## Requirements
129
+
130
+ - Ruby 3.2 or newer.
131
+ - Rails 8.1 or newer.
132
+ - Administrate 1.0.0.beta3 or newer, below 2.0 (the search implementation calls methods
133
+ `Administrate::Search` treats as internal, see [Search](docs/dashboards.md#search)).
134
+ - PostgreSQL. The migrations create uuid primary keys defaulted with `gen_random_uuid()` and store
135
+ the OAuth `redirect_uris` and `grant_types` as array columns.
136
+
137
+ ## Installation
138
+
139
+ ```ruby
140
+ # Gemfile
141
+ gem 'administrate-mcp'
142
+ ```
143
+
144
+ Copy the migrations and run them:
145
+
146
+ ```sh
147
+ bundle install
148
+ bin/rails administrate_mcp:install:migrations
149
+ bin/rails db:migrate
150
+ ```
151
+
152
+ The tables are `administrate_mcp_api_keys`, `administrate_mcp_feedbacks`,
153
+ `administrate_mcp_oauth_applications`, `administrate_mcp_oauth_access_grants` and
154
+ `administrate_mcp_oauth_access_tokens`. They use uuid primary keys and a uuid `admin_id` column
155
+ that is indexed but carries no foreign key constraint, so the engine works with any admin table.
156
+
157
+ ## Quick start
158
+
159
+ The smallest configuration that works. Save it as `config/initializers/administrate_mcp.rb`; it
160
+ must run before the engine's models load, because the `admin` association reads `admin_class_name`:
161
+
162
+ ```ruby
163
+ Administrate::MCP.configure do |c|
164
+ c.admin_class_name = 'Administrator'
165
+ c.current_admin = ->(controller) { controller.send(:warden)&.authenticate(scope: :administrator) }
166
+ c.issuer = 'https://admin-mcp.example.com'
167
+ c.admin_origin = 'https://admin.example.com'
168
+ end
169
+ ```
170
+
171
+ Then draw the routes, split across the MCP origin and the admin origin (see [Routes](#routes) for
172
+ why):
173
+
174
+ ```ruby
175
+ # config/routes.rb
176
+ Rails.application.routes.draw do
177
+ constraints ->(request) { request.subdomain == 'admin-mcp' } do
178
+ Administrate::MCP::Routes.draw_mcp_origin(self)
179
+ end
180
+
181
+ constraints subdomain: 'admin' do
182
+ Administrate::MCP::Routes.draw_admin_origin(self)
183
+ end
184
+ end
185
+ ```
186
+
187
+ This draws a full OAuth 2.1 server by default (see [OAuth](#oauth) to turn it off) and grants every
188
+ authenticated admin access to every dashboard until you set `c.authorization`. For the full picture,
189
+ including hooks, authorization adapters, field serializers and identity fallback, see the reference
190
+ sections below.
191
+
192
+ ## Configuration
193
+
194
+ The full `Configuration` object, the settings table, the authorization adapters, and how to
195
+ register, skip or reclassify a field class: [docs/configuration.md](docs/configuration.md).
196
+
197
+ ## Routes
198
+
199
+ Why the consent screen and the JSON-RPC endpoint are drawn on separate origins, and what each route
200
+ helper adds: [docs/routes.md](docs/routes.md).
201
+
202
+ ## Dashboard declarations
203
+
204
+ `MCP_DESCRIPTION`, `MCP_BASE_SCOPE`, `MCP_SKIPPED_ATTRIBUTES`, `MCP_EXPOSED`, `COLLECTION_FILTERS`
205
+ and `mcp_action`, the constants and macro that turn one dashboard into an MCP resource with its own
206
+ readable fields and writable actions: [docs/dashboards.md](docs/dashboards.md).
207
+
208
+ ## Authentication
209
+
210
+ How a request is authenticated (API key, then OAuth token, then `identity_fallback`), how to issue
211
+ and rotate API keys, and the `identity_fallback` recipe for an identity resolved in front of the
212
+ application: [docs/authentication.md](docs/authentication.md). A Cloudflare Access verifier ships
213
+ with the gem for hosts that run edge-managed OAuth in front of the application:
214
+ [docs/authentication.md#identity-fallback](docs/authentication.md#identity-fallback).
215
+
216
+ ## OAuth
217
+
218
+ The built-in OAuth 2.1 server, what turning it off with `c.oauth = false` changes, and when a host
219
+ should: [docs/oauth.md](docs/oauth.md).
220
+
221
+ ## Rate limiting
222
+
223
+ The rack-attack throttles recommended for the OAuth endpoints, and the helper that registers them
224
+ for you: [docs/oauth.md#rate-limiting](docs/oauth.md#rate-limiting).
225
+
226
+ ## Admin integration
227
+
228
+ Exposing the engine's own tables (API keys, feedback) in your host admin, customising the consent
229
+ screen, and cleaning up old feedback: [docs/admin-integration.md](docs/admin-integration.md).
230
+
231
+ ## Development
232
+
233
+ ```sh
234
+ bundle install
235
+ bundle exec rspec
236
+ bundle exec rubocop
237
+ ```
238
+
239
+ Needs a reachable PostgreSQL server; the test suite creates its own database on first run. See
240
+ [docs/development.md](docs/development.md) for the dummy application and the Postgres environment
241
+ variables.
242
+
243
+ ## Security
244
+
245
+ See [SECURITY.md](SECURITY.md) for how to report a vulnerability.
246
+
247
+ The JSON-RPC endpoint authenticates by bearer token only. It never falls back to a session cookie,
248
+ so a browser signed into the admin UI cannot drive the protocol endpoint. API keys, OAuth access and
249
+ refresh tokens, and OAuth authorization codes are all stored as SHA-256 digests, never in plaintext.
250
+ The Cloudflare Access verifier fails closed: a blank team domain or audience makes it refuse every
251
+ request rather than admit an unverified one. A credential does not outlive the admin who holds it,
252
+ because `admin_active` is checked on every call, whether the credential is an API key, an OAuth
253
+ token, or an identity resolved through `identity_fallback`.
254
+
255
+ ## Contributing
256
+
257
+ Bug reports and pull requests are welcome on GitHub. See [CONTRIBUTING.md](CONTRIBUTING.md) for the
258
+ development workflow, and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for how we expect people to treat
259
+ each other in this project's spaces.
260
+
261
+ ## Changelog
262
+
263
+ See [CHANGELOG.md](CHANGELOG.md) for a history of releases.
264
+
265
+ ## Licence
266
+
267
+ MIT. See [LICENSE.txt](LICENSE.txt). Copyright Sorare.
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ # Streamable HTTP endpoint for MCP JSON-RPC requests.
6
+ # Handles POST (JSON-RPC), GET (SSE, rejected in stateless mode), and DELETE (session close).
7
+ #
8
+ # Authentication is bearer-token only. It inherits from ActionController::API so no session or
9
+ # cookie ever reaches it: hosts share a session cookie across sibling subdomains, and a browser
10
+ # that is signed into the admin UI must not thereby be able to drive the protocol endpoint.
11
+ class JsonRpcController < ActionController::API
12
+ AUTHORIZATION_ERROR_CODE = -32_003
13
+
14
+ before_action :authenticate_admin!
15
+
16
+ def handle
17
+ server, server_context = setup_server
18
+ status, headers, body = dispatch_request(server)
19
+
20
+ if (authz_error = server_context[:authorization_errors].first)
21
+ render_authorization_error(authz_error, extract_jsonrpc_id(body))
22
+ return
23
+ end
24
+
25
+ self.status = status
26
+ headers.each { |key, value| response.headers[key] = value }
27
+ self.response_body = body
28
+ end
29
+
30
+ private
31
+
32
+ def dispatch_request(server)
33
+ rack_request = Rack::Request.new(request.env)
34
+ server.transport.handle_request(rack_request)
35
+ end
36
+
37
+ def setup_server
38
+ server_context = { admin: @identity.admin, scopes: @identity.scopes }
39
+ [ServerBuilder.build(server_context:), server_context]
40
+ end
41
+
42
+ def authenticate_admin!
43
+ @identity = Authentication.authenticate!(request)
44
+ rescue Authentication::Error => e
45
+ response.headers['X-Auth-Error'] = e.auth_error_type
46
+ response.headers['WWW-Authenticate'] = www_authenticate_header
47
+ render json: {
48
+ jsonrpc: '2.0',
49
+ error: {
50
+ code: e.jsonrpc_error_code,
51
+ message: e.message
52
+ },
53
+ id: nil
54
+ },
55
+ status: :unauthorized
56
+ end
57
+
58
+ def www_authenticate_header
59
+ issuer = Administrate::MCP.config.issuer_for(request)
60
+ "Bearer resource_metadata=\"#{issuer}/.well-known/oauth-protected-resource\""
61
+ end
62
+
63
+ def render_authorization_error(message, jsonrpc_id)
64
+ response.headers['X-Auth-Error'] = 'forbidden'
65
+ render json: {
66
+ jsonrpc: '2.0',
67
+ error: {
68
+ code: AUTHORIZATION_ERROR_CODE,
69
+ message:
70
+ },
71
+ id: jsonrpc_id
72
+ },
73
+ status: :forbidden
74
+ end
75
+
76
+ def extract_jsonrpc_id(body)
77
+ JSON.parse(body.first)['id']
78
+ rescue StandardError
79
+ nil
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administrate
4
+ module MCP
5
+ # OAuth 2.1 endpoints for MCP authentication.
6
+ class OAuthController < ActionController::Base
7
+ skip_forgery_protection only: %i[resource_metadata server_metadata register token]
8
+ before_action :authenticate_admin!, only: %i[authorize approve]
9
+
10
+ def resource_metadata
11
+ render json: {
12
+ resource: "#{mcp_origin}/",
13
+ authorization_servers: [mcp_origin],
14
+ bearer_methods_supported: ['header']
15
+ }
16
+ end
17
+
18
+ def server_metadata
19
+ render json: {
20
+ issuer: mcp_origin,
21
+ authorization_endpoint: "#{admin_origin}#{Routes::AUTHORIZE_PATH}",
22
+ token_endpoint: "#{mcp_origin}/oauth/token",
23
+ registration_endpoint: "#{mcp_origin}/oauth/register",
24
+ response_types_supported: ['code'],
25
+ grant_types_supported: %w[authorization_code refresh_token],
26
+ code_challenge_methods_supported: ['S256'],
27
+ token_endpoint_auth_methods_supported: ['none']
28
+ }
29
+ end
30
+
31
+ def register
32
+ application = build_application
33
+ return render(json: registration_error(application), status: :bad_request) unless application.save
34
+
35
+ render json: {
36
+ client_id: application.client_id,
37
+ client_name: application.name,
38
+ redirect_uris: application.redirect_uris,
39
+ grant_types: application.grant_types
40
+ },
41
+ status: :created
42
+ end
43
+
44
+ def authorize
45
+ @application = OAuthApplication.find_by(client_id: params[:client_id])
46
+ error =
47
+ oauth_service.validate_authorize_params(
48
+ application: @application,
49
+ redirect_uri: params[:redirect_uri],
50
+ code_challenge_method: params[:code_challenge_method],
51
+ code_challenge: params[:code_challenge]
52
+ )
53
+ return render plain: error, status: :bad_request if error
54
+
55
+ assign_consent_details
56
+ render 'administrate/mcp/o_auth/authorize', layout: false
57
+ end
58
+
59
+ def approve
60
+ application = find_application!
61
+ return unless application
62
+
63
+ redirect_uri = validated_redirect_uri!(application)
64
+ return unless redirect_uri
65
+
66
+ if params[:deny].present?
67
+ return redirect_to callback_url(redirect_uri, error: 'access_denied'), allow_other_host: true
68
+ end
69
+
70
+ grant = create_access_grant(application, redirect_uri)
71
+ redirect_to callback_url(redirect_uri, code: grant.plaintext_token), allow_other_host: true
72
+ end
73
+
74
+ def token
75
+ case params[:grant_type]
76
+ when 'authorization_code'
77
+ handle_authorization_code
78
+ when 'refresh_token'
79
+ handle_refresh_token
80
+ else
81
+ render json: { error: 'unsupported_grant_type' }, status: :bad_request
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ def build_application
88
+ OAuthApplication.new(
89
+ client_id: OAuthApplication.generate_client_id,
90
+ name: register_params[:client_name] || Administrate::MCP.config.default_client_name,
91
+ redirect_uris: register_params[:redirect_uris] || []
92
+ )
93
+ end
94
+
95
+ def registration_error(application)
96
+ { error: 'invalid_client_metadata', error_description: application.errors.full_messages.join(', ') }
97
+ end
98
+
99
+ def handle_authorization_code
100
+ result =
101
+ oauth_service.exchange_authorization_code(
102
+ code: params[:code],
103
+ code_verifier: params[:code_verifier],
104
+ redirect_uri: params[:redirect_uri]
105
+ )
106
+ render_token_result(result)
107
+ end
108
+
109
+ def handle_refresh_token
110
+ result = oauth_service.exchange_refresh_token(refresh_token: params[:refresh_token])
111
+ render_token_result(result)
112
+ end
113
+
114
+ def render_token_result(result)
115
+ if result.success?
116
+ render json: result.data
117
+ else
118
+ body = { error: result.error }
119
+ body[:error_description] = result.error_description if result.error_description
120
+ render json: body, status: :bad_request
121
+ end
122
+ end
123
+
124
+ def create_access_grant(application, redirect_uri)
125
+ OAuthAccessGrant.issue(
126
+ admin: current_admin,
127
+ application:,
128
+ expires_in: OAuthAccessGrant::DEFAULT_EXPIRES_IN,
129
+ redirect_uri:,
130
+ code_challenge: params[:code_challenge],
131
+ code_challenge_method: params[:code_challenge_method].presence || 'S256',
132
+ scopes: params[:scope].to_s
133
+ )
134
+ end
135
+
136
+ def find_application!
137
+ application = OAuthApplication.find_by(client_id: params[:client_id])
138
+ render(plain: 'Unknown client_id', status: :bad_request) unless application
139
+ application
140
+ end
141
+
142
+ def validated_redirect_uri!(application)
143
+ uri = params[:redirect_uri]
144
+ return uri if oauth_service.valid_redirect_uri?(application, uri)
145
+
146
+ render(plain: 'Invalid redirect_uri', status: :bad_request)
147
+ nil
148
+ end
149
+
150
+ # A registered callback may already carry a query string, so the response parameters are
151
+ # appended to it rather than started with a second `?`.
152
+ def callback_url(redirect_uri, **response_params)
153
+ uri = URI.parse(redirect_uri)
154
+ existing = URI.decode_www_form(uri.query.to_s)
155
+ uri.query = URI.encode_www_form(existing + response_params.merge(state: params[:state].to_s).to_a)
156
+ uri.to_s
157
+ end
158
+
159
+ def assign_consent_details
160
+ @redirect_uri = params[:redirect_uri]
161
+ @scopes = params[:scope].to_s.split
162
+ @redirect_host = URI.parse(@redirect_uri.to_s).host
163
+ rescue URI::InvalidURIError
164
+ @redirect_host = nil
165
+ end
166
+
167
+ def register_params
168
+ params.permit(:client_name, redirect_uris: [])
169
+ end
170
+
171
+ def oauth_service
172
+ @oauth_service ||= OAuthService.new
173
+ end
174
+
175
+ def authenticate_admin!
176
+ return if current_admin
177
+
178
+ sign_in = Administrate::MCP.config.sign_in
179
+ return sign_in.call(self) if sign_in
180
+
181
+ render plain: 'Authentication required', status: :unauthorized
182
+ end
183
+
184
+ def current_admin
185
+ return @current_admin if defined?(@current_admin)
186
+
187
+ @current_admin = Administrate::MCP.config.current_admin.call(self)
188
+ end
189
+
190
+ def mcp_origin
191
+ Administrate::MCP.config.issuer_for(request)
192
+ end
193
+
194
+ def admin_origin
195
+ Administrate::MCP.config.admin_origin_for(request)
196
+ end
197
+ end
198
+ end
199
+ end