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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +148 -0
  3. data/app/controllers/hub_kernel/mcp/authorizations_controller.rb +69 -0
  4. data/app/controllers/hub_kernel/mcp/discovery_controller.rb +29 -0
  5. data/app/controllers/hub_kernel/mcp/registrations_controller.rb +28 -0
  6. data/app/controllers/hub_kernel/mcp/tokens_controller.rb +51 -0
  7. data/app/models/hub_kernel/mcp/authorization_code.rb +27 -0
  8. data/app/models/hub_kernel/mcp/client.rb +32 -0
  9. data/app/models/hub_kernel/mcp/connection.rb +50 -0
  10. data/app/models/hub_kernel/mcp/disconnect.rb +19 -0
  11. data/app/models/hub_kernel/mcp/prune.rb +12 -0
  12. data/app/views/hub_kernel/mcp/_connections.html.erb +10 -0
  13. data/app/views/hub_kernel/mcp/authorizations/new.html.erb +9 -0
  14. data/app/views/hub_kernel/mcp/authorizations/refused.html.erb +1 -0
  15. data/config/routes.rb +4 -0
  16. data/db/migrate/20261007000000_create_hub_kernel_mcp_clients.rb +10 -0
  17. data/db/migrate/20261008000000_create_hub_kernel_mcp_authorization_codes.rb +13 -0
  18. data/db/migrate/20261009000000_create_hub_kernel_mcp_connections.rb +11 -0
  19. data/db/migrate/20261009000001_add_refresh_token_to_hub_kernel_mcp_connections.rb +7 -0
  20. data/db/migrate/20261009000002_add_last_used_at_to_hub_kernel_mcp_connections.rb +5 -0
  21. data/db/migrate/20261009000003_add_code_digest_to_hub_kernel_mcp_connections.rb +6 -0
  22. data/db/migrate/20261009000004_add_registered_from_to_hub_kernel_mcp_clients.rb +6 -0
  23. data/lib/hub_kernel/mcp/challenge.rb +21 -0
  24. data/lib/hub_kernel/mcp/discovery.rb +12 -0
  25. data/lib/hub_kernel/mcp/engine.rb +4 -0
  26. data/lib/hub_kernel/mcp/version.rb +1 -1
  27. data/lib/hub_kernel/mcp.rb +14 -2
  28. data/lib/tasks/hub_kernel_mcp.rake +6 -0
  29. data/the_local/agents/hub_kernel-mcp-develop.md +370 -74
  30. data/the_local/agents/hub_kernel-mcp-info.md +133 -12
  31. data/the_local/agents/hub_kernel-mcp-install.md +284 -20
  32. data/the_local/interface.yml +45 -1
  33. metadata +23 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c892de0b21d83e1d2068595a30e59930be423fa5b30dc539ffaedf3c304be263
4
- data.tar.gz: 22114b0e2e4a5aa6c3389dc7c7580079a9091a5ac46bcfc6e7b53585eddcce16
3
+ metadata.gz: 6c25e5f3f088ea410414724954e79aaf5571f10d7c9060ad2a0a6298487df3fb
4
+ data.tar.gz: 2cc4f2810b8804056784ff5eae16d4a40134d43bdb61e06dca949f4ca9230ef1
5
5
  SHA512:
6
- metadata.gz: 507a819510592862a8a2641075062fecf56f052de877ee8b7dc2fc7dd1ac66b7aace212dd7272cc33c6c65d80a1f9dbd792c7b63cf326a378fdbacca0a257dfb
7
- data.tar.gz: b9e8d051ef5ef2f781499d69d17866e29b91475a7ba9e49f3648a8c10fcaa3f644da6acac643398d06421ee58e89479dd80c99dc3f7e50456bb848c0b6e3fc4f
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,5 @@
1
+ class AddLastUsedAtToHubKernelMcpConnections < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :hub_kernel_mcp_connections, :last_used_at, :datetime
4
+ end
5
+ end
@@ -0,0 +1,6 @@
1
+ class AddCodeDigestToHubKernelMcpConnections < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :hub_kernel_mcp_connections, :code_digest, :string
4
+ add_index :hub_kernel_mcp_connections, :code_digest
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ class AddRegisteredFromToHubKernelMcpClients < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :hub_kernel_mcp_clients, :registered_from, :string
4
+ add_index :hub_kernel_mcp_clients, [ :registered_from, :created_at ]
5
+ end
6
+ 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
@@ -1,7 +1,11 @@
1
+ require "hub_kernel/mcp/challenge"
2
+
1
3
  module HubKernel
2
4
  module Mcp
3
5
  class Engine < ::Rails::Engine
4
6
  isolate_namespace HubKernel::Mcp
7
+
8
+ middleware.use HubKernel::Mcp::Challenge
5
9
  end
6
10
  end
7
11
  end
@@ -1,5 +1,5 @@
1
1
  module HubKernel
2
2
  module Mcp
3
- VERSION = "0.7.0"
3
+ VERSION = "0.8.0"
4
4
  end
5
5
  end