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
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
|
+
[](https://rubygems.org/gems/administrate-mcp)
|
|
9
|
+
[](https://github.com/sorare/administrate-mcp/actions/workflows/ci.yml)
|
|
10
|
+
[](LICENSE.txt)
|
|
11
|
+
[](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
|