hub_kernel-mcp 0.7.0 → 0.8.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 +4 -4
- data/README.md +148 -0
- data/app/controllers/hub_kernel/mcp/authorizations_controller.rb +69 -0
- data/app/controllers/hub_kernel/mcp/discovery_controller.rb +29 -0
- data/app/controllers/hub_kernel/mcp/registrations_controller.rb +28 -0
- data/app/controllers/hub_kernel/mcp/tokens_controller.rb +51 -0
- data/app/models/hub_kernel/mcp/authorization_code.rb +27 -0
- data/app/models/hub_kernel/mcp/client.rb +32 -0
- data/app/models/hub_kernel/mcp/connection.rb +50 -0
- data/app/models/hub_kernel/mcp/disconnect.rb +19 -0
- data/app/models/hub_kernel/mcp/prune.rb +12 -0
- data/app/views/hub_kernel/mcp/_connections.html.erb +10 -0
- data/app/views/hub_kernel/mcp/authorizations/new.html.erb +9 -0
- data/app/views/hub_kernel/mcp/authorizations/refused.html.erb +1 -0
- data/config/routes.rb +4 -0
- data/db/migrate/20261007000000_create_hub_kernel_mcp_clients.rb +10 -0
- data/db/migrate/20261008000000_create_hub_kernel_mcp_authorization_codes.rb +13 -0
- data/db/migrate/20261009000000_create_hub_kernel_mcp_connections.rb +11 -0
- data/db/migrate/20261009000001_add_refresh_token_to_hub_kernel_mcp_connections.rb +7 -0
- data/db/migrate/20261009000002_add_last_used_at_to_hub_kernel_mcp_connections.rb +5 -0
- data/db/migrate/20261009000003_add_code_digest_to_hub_kernel_mcp_connections.rb +6 -0
- data/db/migrate/20261009000004_add_registered_from_to_hub_kernel_mcp_clients.rb +6 -0
- data/lib/hub_kernel/mcp/challenge.rb +21 -0
- data/lib/hub_kernel/mcp/discovery.rb +12 -0
- data/lib/hub_kernel/mcp/engine.rb +4 -0
- data/lib/hub_kernel/mcp/version.rb +1 -1
- data/lib/hub_kernel/mcp.rb +14 -2
- data/lib/tasks/hub_kernel_mcp.rake +6 -0
- data/the_local/agents/hub_kernel-mcp-develop.md +370 -74
- data/the_local/agents/hub_kernel-mcp-info.md +133 -12
- data/the_local/agents/hub_kernel-mcp-install.md +284 -20
- data/the_local/interface.yml +45 -1
- metadata +23 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6c25e5f3f088ea410414724954e79aaf5571f10d7c9060ad2a0a6298487df3fb
|
|
4
|
+
data.tar.gz: 2cc4f2810b8804056784ff5eae16d4a40134d43bdb61e06dca949f4ca9230ef1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6e0a335dfa07e60b06c07c3d7f29557c3a9983dc8195ba5301a3b26d1fa3bb14f9925e1a9e76fa911b6e09c35efdbb9e9d84f81923deafafde457de86f28af96
|
|
7
|
+
data.tar.gz: 9ba71b706c1b4b06fcfd9661515f8902837d91a6715f6e84e767b4e6ea12f766fc553b39eb0d7cc367ad871d30c176de35ed837d2567e03ab77c19b116890968
|
data/README.md
CHANGED
|
@@ -89,6 +89,154 @@ An unexpected error's own message is never sent to the client. It is reported to
|
|
|
89
89
|
app's error reporting through `Rails.error`. A GET to the endpoint's address is answered with
|
|
90
90
|
status 405, since the endpoint offers no sessions or server-sent events.
|
|
91
91
|
|
|
92
|
+
## Signing in from a connector screen
|
|
93
|
+
|
|
94
|
+
A client such as Claude's connector screen finds the endpoint's sign-in by itself and
|
|
95
|
+
registers there, so a person only pastes the endpoint's address. Install the gem's migrations,
|
|
96
|
+
then mount its discovery documents at the site root beside the endpoint:
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
bin/rails hub_kernel_mcp:install:migrations db:migrate
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
mount HubKernel::Mcp::Engine => "/mcp"
|
|
104
|
+
mount HubKernel::Mcp::Discovery => "/.well-known"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Every 401 from the endpoint carries a `WWW-Authenticate` header naming where the endpoint is
|
|
108
|
+
described:
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource/mcp"
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
That document names the endpoint's address and its sign-in, the site root. The sign-in's own
|
|
115
|
+
document, at `/.well-known/oauth-authorization-server`, names the registration, approval and
|
|
116
|
+
token addresses under the endpoint's address, such as `/mcp/register`, and says a client must
|
|
117
|
+
use PKCE with SHA-256.
|
|
118
|
+
|
|
119
|
+
A client registers by posting its name and redirect addresses to the registration address:
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
{ "client_name": "Claude", "redirect_uris": [ "https://claude.ai/api/mcp/auth_callback" ] }
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
It is answered with status 201 and a client id. A registration with no redirect address, or
|
|
126
|
+
with one that is neither HTTPS nor on the client's own machine, is answered with status 400,
|
|
127
|
+
`invalid_redirect_uri`, and the reason. A body that is not valid JSON is answered with status
|
|
128
|
+
400 and `invalid_client_metadata`. One address may register ten clients an hour, and a
|
|
129
|
+
registration past that is answered with status 429 and `too_many_registrations`. A host sets a
|
|
130
|
+
different limit with:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
HubKernel::Mcp.registration_limit = 20
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The approval address is a browser page. It runs inside a controller of the host's, so the
|
|
137
|
+
host's own sign-in decides who is approving. Name that controller, the method on it that signs a
|
|
138
|
+
person in, the method that gives the signed-in person, and the layout the page is shown in:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
HubKernel::Mcp.browser_controller = "ApplicationController"
|
|
142
|
+
HubKernel::Mcp.sign_in_method = :authenticate_user!
|
|
143
|
+
HubKernel::Mcp.browser_person_method = :current_user
|
|
144
|
+
HubKernel::Mcp.browser_layout = "application"
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`HubKernel::Mcp.check!` names each of the four a host has not set. The person the page gives is
|
|
148
|
+
kept by its global id, so it must be a record that has one.
|
|
149
|
+
|
|
150
|
+
A person who is not signed in is sent through the host's sign-in. A signed-in person sees the
|
|
151
|
+
name of the app asking to connect, with an Approve and a Deny button. Approving sends them back
|
|
152
|
+
to the client's redirect address with a code and the client's `state`. Denying sends them back
|
|
153
|
+
with `error=access_denied` and no code. A request missing its `client_id` or `redirect_uri`,
|
|
154
|
+
naming a client that is not registered, or naming a redirect address the client did not
|
|
155
|
+
register shows an error page saying which, and sends the person nowhere. A request with no PKCE
|
|
156
|
+
challenge, or one whose method is not `S256`, is sent back with `error=invalid_request`.
|
|
157
|
+
|
|
158
|
+
The client trades the code at the token address, posting it form-encoded with its PKCE verifier,
|
|
159
|
+
the redirect address it was approved for and its client id:
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
grant_type=authorization_code&code=<code>&code_verifier=<verifier>&redirect_uri=<redirect>&client_id=<client id>
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
It is answered with an access token that lasts an hour and a refresh token:
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
{ "access_token": "<token>", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "<refresh token>" }
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
A code is used up by the trade. A code posted with a verifier that does not match its challenge,
|
|
172
|
+
with another client id or redirect address, a second time, or more than ten minutes after it was
|
|
173
|
+
made is answered with status 400, `invalid_grant` and a description, and gives no token. A code
|
|
174
|
+
posted a second time also stops the access and refresh tokens already issued from it.
|
|
175
|
+
|
|
176
|
+
When the access token expires, the client posts its refresh token and client id to the same
|
|
177
|
+
address:
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
grant_type=refresh_token&refresh_token=<refresh token>&client_id=<client id>
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
It is answered the same way, with a new access token and a new refresh token, and the refresh
|
|
184
|
+
token it posted stops working. A refresh token posted by a client other than the one it was
|
|
185
|
+
issued to, one already traded, or one unused for ninety days is answered with status 400,
|
|
186
|
+
`invalid_grant` and a description, and the person signs in again. Any other `grant_type` is
|
|
187
|
+
answered with `unsupported_grant_type`, and a body that cannot be read with `invalid_request`.
|
|
188
|
+
|
|
189
|
+
An unexpected error at the registration or token address or a discovery document is answered
|
|
190
|
+
with status 500 and `server_error`, and on the approval page with an error page. Its message is
|
|
191
|
+
never sent to the client or shown to the person. It is reported to the host app's error
|
|
192
|
+
reporting through `Rails.error`.
|
|
193
|
+
|
|
194
|
+
The client then sends the token on every request to the endpoint as `Authorization: Bearer
|
|
195
|
+
<token>`. The host's own sign-in, the base controller the endpoint inherits from, asks the gem
|
|
196
|
+
for the person a token acts for:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
class Api::McpBaseController < ActionController::API
|
|
200
|
+
include ActionController::HttpAuthentication::Token::ControllerMethods
|
|
201
|
+
|
|
202
|
+
before_action { head :unauthorized unless current_person }
|
|
203
|
+
|
|
204
|
+
private
|
|
205
|
+
|
|
206
|
+
def current_person = authenticate_with_http_token { |token| HubKernel::Mcp::Connection.person_for(token) }
|
|
207
|
+
end
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`HubKernel::Mcp::Connection.person_for` gives the person while the token is unexpired, and `nil`
|
|
211
|
+
for an expired or unknown token. Every call is made in the account the host's account method
|
|
212
|
+
gives, so that method must give one for a person signed in this way.
|
|
213
|
+
|
|
214
|
+
A person can see the apps connected as them and disconnect one. The gem ships a section for a
|
|
215
|
+
host's settings page: the `hub_kernel/mcp/connections` partial, which lists each of the person's
|
|
216
|
+
connections with the app's name, when it connected and when it was last used, and the
|
|
217
|
+
`HubKernel::Mcp::Disconnect` action its Disconnect buttons run. A host using settings_hub
|
|
218
|
+
registers both:
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
SettingsHub.section :connections, area: :user, title: "Connected apps",
|
|
222
|
+
renders: "hub_kernel/mcp/connections", runs: "HubKernel::Mcp::Disconnect"
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
The partial takes the `person` and the `submit_url` its buttons send a `connection_id` to by
|
|
226
|
+
PATCH. The action is built with `new(person:, account:, values:)`, and `call` answers a result
|
|
227
|
+
whose `ok?` is false, with a `message`, when the connection is not the person's own. A
|
|
228
|
+
disconnected connection's access and refresh tokens stop working at once.
|
|
229
|
+
|
|
230
|
+
A host runs one task, on whatever schedule its job runner keeps, to remove sign-in records that
|
|
231
|
+
can no longer be used: authorization codes past their ten minutes, connections whose access and
|
|
232
|
+
refresh tokens have both expired, and clients over a day old with no connection.
|
|
233
|
+
|
|
234
|
+
```sh
|
|
235
|
+
bin/rails hub_kernel_mcp:prune
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
A connection whose access token or refresh token still works is never removed.
|
|
239
|
+
|
|
92
240
|
## Installation
|
|
93
241
|
|
|
94
242
|
```ruby
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module HubKernel
|
|
4
|
+
module Mcp
|
|
5
|
+
class AuthorizationsController < HubKernel::Mcp.browser_controller.constantize
|
|
6
|
+
APPROVAL_PARAMS = %i[response_type client_id redirect_uri state code_challenge code_challenge_method].freeze
|
|
7
|
+
|
|
8
|
+
layout -> { HubKernel::Mcp.browser_layout }
|
|
9
|
+
|
|
10
|
+
before_action { send(HubKernel::Mcp.sign_in_method) }
|
|
11
|
+
around_action :report_unexpected_errors
|
|
12
|
+
before_action :refuse_missing_values, :refuse_unknown_client, :refuse_unregistered_redirect
|
|
13
|
+
before_action :refuse_without_pkce
|
|
14
|
+
|
|
15
|
+
helper_method :approval_params
|
|
16
|
+
|
|
17
|
+
def new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def create
|
|
21
|
+
return redirect_to_client(error: "access_denied") unless params[:decision] == "approve"
|
|
22
|
+
|
|
23
|
+
code = AuthorizationCode.issue(person: send(HubKernel::Mcp.browser_person_method), client: @client, redirect_uri: params[:redirect_uri], code_challenge: params[:code_challenge])
|
|
24
|
+
redirect_to_client(code: code)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def report_unexpected_errors
|
|
30
|
+
yield
|
|
31
|
+
rescue StandardError => error
|
|
32
|
+
Rails.error.report(error, handled: true)
|
|
33
|
+
@reason = "Signing in failed unexpectedly"
|
|
34
|
+
render :refused, status: :internal_server_error
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def refuse_missing_values
|
|
38
|
+
missing = %i[client_id redirect_uri].find { |name| params[name].blank? }
|
|
39
|
+
refuse_on_page("The approval request is missing #{missing}") if missing
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def refuse_unknown_client
|
|
43
|
+
@client = Client.find_by(uid: params[:client_id])
|
|
44
|
+
refuse_on_page("The app asking to connect is not registered") unless @client
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def refuse_unregistered_redirect
|
|
48
|
+
refuse_on_page("This app asked to send you to an address it did not register") unless @client.redirect_uris.include?(params[:redirect_uri])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def refuse_on_page(reason)
|
|
52
|
+
@reason = reason
|
|
53
|
+
render :refused, status: :bad_request
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def refuse_without_pkce
|
|
57
|
+
redirect_to_client(error: "invalid_request", error_description: "A PKCE challenge using S256 is required") unless params[:code_challenge].present? && params[:code_challenge_method] == "S256"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def redirect_to_client(answer)
|
|
61
|
+
uri = URI.parse(params[:redirect_uri])
|
|
62
|
+
uri.query = [ uri.query, answer.merge(state: params[:state]).compact.to_query ].compact_blank.join("&")
|
|
63
|
+
redirect_to uri.to_s, allow_other_host: true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def approval_params = params.permit(*APPROVAL_PARAMS)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module HubKernel
|
|
2
|
+
module Mcp
|
|
3
|
+
class DiscoveryController < ActionController::API
|
|
4
|
+
rescue_from(StandardError) do |error|
|
|
5
|
+
Rails.error.report(error, handled: true)
|
|
6
|
+
render status: :internal_server_error, json: { error: "server_error", error_description: "The sign-in failed unexpectedly" }
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def resource = render(json: { resource: endpoint_url, authorization_servers: [ request.base_url ] })
|
|
10
|
+
|
|
11
|
+
def sign_in
|
|
12
|
+
render json: {
|
|
13
|
+
issuer: request.base_url,
|
|
14
|
+
registration_endpoint: "#{endpoint_url}/register",
|
|
15
|
+
authorization_endpoint: "#{endpoint_url}/authorize",
|
|
16
|
+
token_endpoint: "#{endpoint_url}/token",
|
|
17
|
+
response_types_supported: [ "code" ],
|
|
18
|
+
grant_types_supported: [ "authorization_code", "refresh_token" ],
|
|
19
|
+
token_endpoint_auth_methods_supported: [ "none" ],
|
|
20
|
+
code_challenge_methods_supported: [ "S256" ]
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def endpoint_url = request.base_url + HubKernel::Mcp::Engine.routes.find_script_name({})
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module HubKernel
|
|
2
|
+
module Mcp
|
|
3
|
+
class RegistrationsController < ActionController::API
|
|
4
|
+
rescue_from(StandardError) do |error|
|
|
5
|
+
Rails.error.report(error, handled: true)
|
|
6
|
+
render status: :internal_server_error, json: { error: "server_error", error_description: "The sign-in failed unexpectedly" }
|
|
7
|
+
end
|
|
8
|
+
rescue_from(ActionDispatch::Http::Parameters::ParseError) { render(status: :bad_request, json: { error: "invalid_client_metadata", error_description: "The registration body is not valid JSON" }) }
|
|
9
|
+
|
|
10
|
+
def create
|
|
11
|
+
return refuse_past_limit if Client.where(registered_from: request.remote_ip, created_at: 1.hour.ago..).count >= HubKernel::Mcp.registration_limit
|
|
12
|
+
|
|
13
|
+
client = Client.new(name: params[:client_name], redirect_uris: Array(params[:redirect_uris]), registered_from: request.remote_ip)
|
|
14
|
+
return refuse(client) unless client.save
|
|
15
|
+
|
|
16
|
+
render status: :created, json: { client_id: client.uid, client_name: client.name, redirect_uris: client.redirect_uris, token_endpoint_auth_method: "none" }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def refuse_past_limit
|
|
22
|
+
render status: :too_many_requests, json: { error: "too_many_registrations", error_description: "This address has registered #{HubKernel::Mcp.registration_limit} clients in the last hour, which is the limit" }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def refuse(client) = render(status: :bad_request, json: { error: "invalid_redirect_uri", error_description: client.errors.full_messages.to_sentence })
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module HubKernel
|
|
2
|
+
module Mcp
|
|
3
|
+
class TokensController < ActionController::API
|
|
4
|
+
UNUSABLE_CODE = "The code is unknown, used, expired, or does not match this client, redirect address or verifier".freeze
|
|
5
|
+
UNUSABLE_REFRESH_TOKEN = "The refresh token is unknown, used, unused for ninety days, or belongs to another client".freeze
|
|
6
|
+
|
|
7
|
+
rescue_from(StandardError) do |error|
|
|
8
|
+
Rails.error.report(error, handled: true)
|
|
9
|
+
render status: :internal_server_error, json: { error: "server_error", error_description: "The sign-in failed unexpectedly" }
|
|
10
|
+
end
|
|
11
|
+
rescue_from(ActionDispatch::Http::Parameters::ParseError) { refuse("invalid_request", "The token request body could not be read") }
|
|
12
|
+
|
|
13
|
+
def create
|
|
14
|
+
case params[:grant_type]
|
|
15
|
+
when "authorization_code" then answer(exchanged, UNUSABLE_CODE)
|
|
16
|
+
when "refresh_token" then answer(refreshed, UNUSABLE_REFRESH_TOKEN)
|
|
17
|
+
else refuse("unsupported_grant_type", "The token address takes authorization_code or refresh_token")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def answer(tokens, refusal)
|
|
24
|
+
return refuse("invalid_grant", refusal) unless tokens
|
|
25
|
+
|
|
26
|
+
render json: { access_token: tokens.access_token, token_type: "Bearer", expires_in: Connection::LIFETIME.to_i, refresh_token: tokens.refresh_token }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def refuse(error, description) = render(status: :bad_request, json: { error: error, error_description: description })
|
|
30
|
+
|
|
31
|
+
def exchanged
|
|
32
|
+
code_digest = AuthorizationCode.digest(params[:code].to_s)
|
|
33
|
+
code = AuthorizationCode.find_by(code_digest: code_digest)
|
|
34
|
+
return stop_tokens_issued_from(code_digest) unless code
|
|
35
|
+
|
|
36
|
+
Connection.issue(person: code.person, client: code.client, code_digest: code_digest) if exchangeable?(code)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def stop_tokens_issued_from(code_digest)
|
|
40
|
+
Connection.where(code_digest: code_digest).delete_all
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def refreshed = Connection.refresh(params[:refresh_token], client: Client.find_by(uid: params[:client_id]))
|
|
45
|
+
|
|
46
|
+
def exchangeable?(code) = code.present? && code.expires_at.future? && code.client.uid == params[:client_id] && code.redirect_uri == params[:redirect_uri] && code.verifies?(params[:code_verifier]) && used_up?(code)
|
|
47
|
+
|
|
48
|
+
def used_up?(code) = AuthorizationCode.where(id: code.id).delete_all == 1
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module HubKernel
|
|
6
|
+
module Mcp
|
|
7
|
+
class AuthorizationCode < ActiveRecord::Base
|
|
8
|
+
LIFETIME = 10.minutes
|
|
9
|
+
|
|
10
|
+
belongs_to :client
|
|
11
|
+
|
|
12
|
+
def self.issue(person:, client:, redirect_uri:, code_challenge:)
|
|
13
|
+
code = SecureRandom.urlsafe_base64(32)
|
|
14
|
+
create!(code_digest: digest(code), person_gid: person.to_global_id.to_s, client: client, redirect_uri: redirect_uri, code_challenge: code_challenge, expires_at: LIFETIME.from_now)
|
|
15
|
+
code
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.digest(code) = Digest::SHA256.hexdigest(code)
|
|
19
|
+
|
|
20
|
+
def person = GlobalID::Locator.locate(person_gid)
|
|
21
|
+
|
|
22
|
+
def verifies?(verifier)
|
|
23
|
+
ActiveSupport::SecurityUtils.secure_compare(Base64.urlsafe_encode64(Digest::SHA256.digest(verifier.to_s), padding: false), code_challenge)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module HubKernel
|
|
4
|
+
module Mcp
|
|
5
|
+
class Client < ActiveRecord::Base
|
|
6
|
+
LOCAL_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
|
|
7
|
+
|
|
8
|
+
has_secure_token :uid
|
|
9
|
+
|
|
10
|
+
validate :redirect_uris_named, :redirect_uris_allowed
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def redirect_uris_named
|
|
15
|
+
errors.add(:base, "A client must register at least one redirect address") if redirect_uris.blank?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def redirect_uris_allowed
|
|
19
|
+
Array(redirect_uris).reject { |address| allowed_redirect?(address.to_s) }.each do |address|
|
|
20
|
+
errors.add(:base, "#{address} is neither HTTPS nor on the client's own machine")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def allowed_redirect?(address)
|
|
25
|
+
uri = URI.parse(address)
|
|
26
|
+
uri.hostname.present? && (uri.scheme == "https" || (uri.scheme == "http" && LOCAL_HOSTS.include?(uri.hostname)))
|
|
27
|
+
rescue URI::InvalidURIError
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
|
|
4
|
+
module HubKernel
|
|
5
|
+
module Mcp
|
|
6
|
+
class Connection < ActiveRecord::Base
|
|
7
|
+
LIFETIME = 1.hour
|
|
8
|
+
REFRESH_LIFETIME = 90.days
|
|
9
|
+
|
|
10
|
+
Tokens = Data.define(:access_token, :refresh_token)
|
|
11
|
+
|
|
12
|
+
belongs_to :client
|
|
13
|
+
|
|
14
|
+
scope :of, ->(person) { where(person_gid: person.to_global_id.to_s) }
|
|
15
|
+
|
|
16
|
+
def self.issue(person:, client:, code_digest: nil)
|
|
17
|
+
tokens = new_tokens
|
|
18
|
+
create!(person_gid: person.to_global_id.to_s, client: client, code_digest: code_digest, **digests_for(tokens))
|
|
19
|
+
tokens
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.refresh(refresh_token, client:)
|
|
23
|
+
connection = where(refresh_expires_at: Time.current..).find_by(refresh_token_digest: digest(refresh_token.to_s), client: client)
|
|
24
|
+
return unless connection
|
|
25
|
+
|
|
26
|
+
tokens = new_tokens
|
|
27
|
+
renewed = where(id: connection.id, refresh_token_digest: connection.refresh_token_digest).update_all(updated_at: Time.current, **digests_for(tokens))
|
|
28
|
+
tokens if renewed == 1
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.person_for(token)
|
|
32
|
+
connection = where(expires_at: Time.current..).find_by(token_digest: digest(token.to_s))
|
|
33
|
+
connection&.touch(:last_used_at)
|
|
34
|
+
connection&.person
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.digest(token) = Digest::SHA256.hexdigest(token)
|
|
38
|
+
|
|
39
|
+
def self.new_tokens = Tokens.new(access_token: SecureRandom.urlsafe_base64(32), refresh_token: SecureRandom.urlsafe_base64(32))
|
|
40
|
+
|
|
41
|
+
def self.digests_for(tokens)
|
|
42
|
+
{ token_digest: digest(tokens.access_token), refresh_token_digest: digest(tokens.refresh_token), expires_at: LIFETIME.from_now, refresh_expires_at: REFRESH_LIFETIME.from_now }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private_class_method :new_tokens, :digests_for
|
|
46
|
+
|
|
47
|
+
def person = GlobalID::Locator.locate(person_gid)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module HubKernel
|
|
2
|
+
module Mcp
|
|
3
|
+
class Disconnect
|
|
4
|
+
Result = Data.define(:message) do
|
|
5
|
+
def ok? = message.nil?
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def initialize(values:, person: nil, account: nil)
|
|
9
|
+
@person = person
|
|
10
|
+
@values = values
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
disconnected = Connection.of(@person).where(id: @values[:connection_id]).delete_all
|
|
15
|
+
Result.new(message: disconnected.zero? ? "That connection is not one of yours" : nil)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module HubKernel
|
|
2
|
+
module Mcp
|
|
3
|
+
module Prune
|
|
4
|
+
def self.call
|
|
5
|
+
now = Time.current
|
|
6
|
+
AuthorizationCode.where(expires_at: ...now).delete_all
|
|
7
|
+
Connection.where(expires_at: ...now).where("refresh_expires_at IS NULL OR refresh_expires_at < ?", now).delete_all
|
|
8
|
+
Client.where(created_at: ...1.day.ago).where.not(id: Connection.select(:client_id)).where.not(id: AuthorizationCode.select(:client_id)).delete_all
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<ul>
|
|
2
|
+
<% HubKernel::Mcp::Connection.of(person).includes(:client).order(:created_at).each do |connection| %>
|
|
3
|
+
<li>
|
|
4
|
+
<strong><%= connection.client.name %></strong>
|
|
5
|
+
<span>Connected <%= l(connection.created_at, format: :long) %></span>
|
|
6
|
+
<span><%= connection.last_used_at ? "Last used #{l(connection.last_used_at, format: :long)}" : "Never used" %></span>
|
|
7
|
+
<%= button_to "Disconnect", submit_url, method: :patch, params: { connection_id: connection.id } %>
|
|
8
|
+
</li>
|
|
9
|
+
<% end %>
|
|
10
|
+
</ul>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<h1><%= @client.name %> wants to connect as you</h1>
|
|
2
|
+
|
|
3
|
+
<%= form_with url: request.path, method: :post do |form| %>
|
|
4
|
+
<% approval_params.each do |name, value| %>
|
|
5
|
+
<%= hidden_field_tag name, value %>
|
|
6
|
+
<% end %>
|
|
7
|
+
<%= form.button "Approve", name: "decision", value: "approve" %>
|
|
8
|
+
<%= form.button "Deny", name: "decision", value: "deny" %>
|
|
9
|
+
<% end %>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<h1><%= @reason %></h1>
|
data/config/routes.rb
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
HubKernel::Mcp::Engine.routes.draw do
|
|
2
2
|
post "/", to: "messages#create"
|
|
3
3
|
get "/", to: "messages#refuse_get"
|
|
4
|
+
post "register", to: "registrations#create"
|
|
5
|
+
get "authorize", to: "authorizations#new"
|
|
6
|
+
post "authorize", to: "authorizations#create"
|
|
7
|
+
post "token", to: "tokens#create"
|
|
4
8
|
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class CreateHubKernelMcpClients < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :hub_kernel_mcp_clients do |t|
|
|
4
|
+
t.string :uid, null: false, index: { unique: true }
|
|
5
|
+
t.string :name
|
|
6
|
+
t.json :redirect_uris, null: false
|
|
7
|
+
t.timestamps
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class CreateHubKernelMcpAuthorizationCodes < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :hub_kernel_mcp_authorization_codes do |t|
|
|
4
|
+
t.string :code_digest, null: false, index: { unique: true }
|
|
5
|
+
t.references :client, null: false, foreign_key: { to_table: :hub_kernel_mcp_clients }
|
|
6
|
+
t.string :person_gid, null: false
|
|
7
|
+
t.string :redirect_uri, null: false
|
|
8
|
+
t.string :code_challenge, null: false
|
|
9
|
+
t.datetime :expires_at, null: false
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
class CreateHubKernelMcpConnections < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :hub_kernel_mcp_connections do |t|
|
|
4
|
+
t.string :token_digest, null: false, index: { unique: true }
|
|
5
|
+
t.references :client, null: false, foreign_key: { to_table: :hub_kernel_mcp_clients }
|
|
6
|
+
t.string :person_gid, null: false
|
|
7
|
+
t.datetime :expires_at, null: false
|
|
8
|
+
t.timestamps
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
class AddRefreshTokenToHubKernelMcpConnections < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
add_column :hub_kernel_mcp_connections, :refresh_token_digest, :string
|
|
4
|
+
add_column :hub_kernel_mcp_connections, :refresh_expires_at, :datetime
|
|
5
|
+
add_index :hub_kernel_mcp_connections, :refresh_token_digest, unique: true
|
|
6
|
+
end
|
|
7
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
require "rack/request"
|
|
2
|
+
|
|
3
|
+
module HubKernel
|
|
4
|
+
module Mcp
|
|
5
|
+
class Challenge
|
|
6
|
+
def initialize(app)
|
|
7
|
+
@app = app
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(env)
|
|
11
|
+
status, headers, body = @app.call(env)
|
|
12
|
+
headers["www-authenticate"] = challenge(Rack::Request.new(env)) if status == 401
|
|
13
|
+
[ status, headers, body ]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def challenge(request) = %(Bearer resource_metadata="#{request.base_url}/.well-known/oauth-protected-resource#{request.script_name}")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
require "action_dispatch"
|
|
2
|
+
|
|
3
|
+
module HubKernel
|
|
4
|
+
module Mcp
|
|
5
|
+
Discovery = ActionDispatch::Routing::RouteSet.new.tap do |routes|
|
|
6
|
+
routes.draw do
|
|
7
|
+
get "oauth-protected-resource(/*resource)", to: "hub_kernel/mcp/discovery#resource"
|
|
8
|
+
get "oauth-authorization-server", to: "hub_kernel/mcp/discovery#sign_in"
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|