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
@@ -1,6 +1,7 @@
1
1
  require "hub_kernel-interface"
2
2
  require "hub_kernel/mcp/version"
3
3
  require "hub_kernel/mcp/engine"
4
+ require "hub_kernel/mcp/discovery"
4
5
 
5
6
  module HubKernel
6
7
  module Mcp
@@ -9,9 +10,16 @@ module HubKernel
9
10
  mattr_accessor :base_controller, default: "ActionController::API"
10
11
  mattr_accessor :person_method
11
12
  mattr_accessor :account_method
13
+ mattr_accessor :browser_controller
14
+ mattr_accessor :sign_in_method
15
+ mattr_accessor :browser_person_method
16
+ mattr_accessor :browser_layout
17
+ mattr_accessor :registration_limit, default: 10
18
+
19
+ BROWSER_SETTINGS = %i[browser_controller sign_in_method browser_person_method browser_layout].freeze
12
20
 
13
21
  def self.check!
14
- problems = shared_problems + doubled_underscores + unlisted_characters + long_names
22
+ problems = shared_problems + doubled_underscores + unlisted_characters + long_names + unset_browser_settings
15
23
  raise UnservableHubError, problems.join("\n") if problems.any?
16
24
  end
17
25
 
@@ -38,12 +46,16 @@ module HubKernel
38
46
  tool_names.select { |name| name.length > 64 }.map { |name| "The tool #{name} is longer than 64 characters" }
39
47
  end
40
48
 
49
+ def self.unset_browser_settings
50
+ BROWSER_SETTINGS.reject { |setting| public_send(setting) }.map { |setting| "HubKernel::Mcp.#{setting} is not set" }
51
+ end
52
+
41
53
  def self.tool_names
42
54
  HubKernel::Interface.served.select { |_name, hub| hub.respond_to?(:exposures) }.flat_map do |served_name, hub|
43
55
  hub.exposures.map { |exposure| "#{served_name}__#{exposure.name}" }
44
56
  end
45
57
  end
46
58
 
47
- private_class_method :shared_problems, :doubled_underscores, :unlisted_characters, :long_names, :tool_names
59
+ private_class_method :shared_problems, :doubled_underscores, :unlisted_characters, :long_names, :unset_browser_settings, :tool_names
48
60
  end
49
61
  end
@@ -0,0 +1,6 @@
1
+ namespace :hub_kernel_mcp do
2
+ desc "Remove expired authorization codes, connections that can no longer be used, and clients with no connection older than a day"
3
+ task prune: :environment do
4
+ HubKernel::Mcp::Prune.call
5
+ end
6
+ end
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  name: hub_kernel-mcp-develop
3
- description: Use PROACTIVELY for calling a host's hub methods over MCP — connecting an MCP client to the endpoint, sending initialize and ping, listing the tools a signed-in person may call with tools/list, calling one with tools/call, and handling its tool errors and JSON-RPC errors — MUST BE USED instead of hand-rolling a JSON API or MCP server for the host's hubs.
3
+ description: Use PROACTIVELY for calling a host's hub methods over MCP — connecting an MCP client to the endpoint, finding the endpoint's sign-in from a 401's WWW-Authenticate header and the two .well-known discovery documents, registering a client at the registration address and handling a registration refused past the hourly limit, sending a person to the approval page and handling the code or error it sends back, trading that code with its PKCE verifier for an access token and a refresh token at the token address, trading a refresh token for a new pair when the access token expires, reading the reason each sign-in step gives when it refuses, sending the access token on every request, sending initialize and ping, listing the tools a signed-in person may call with tools/list, calling one with tools/call, handling its tool errors and JSON-RPC errors, and reading the apps a person has connected, with when each connected and was last used — MUST BE USED instead of hand-rolling a JSON API, an MCP server, OAuth discovery documents, client registration, an OAuth approval page, an OAuth token endpoint, an OAuth refresh exchange or a query for a person's connected apps for the host's hubs.
4
4
  tools: Read, Write, Edit, Grep
5
- scope: hub MCP tools — serving the methods every hub a host serves, from hub_kernel-interface's one served list, as MCP tools at one JSON-RPC endpoint in a host Rails app, each call behind the host's own sign-in and hub_kernel-interface's permission check and account scope, with a boot check for hubs that cannot be served as tools
5
+ scope: hub MCP tools — serving the methods every hub a host serves, from hub_kernel-interface's one served list, as MCP tools at one JSON-RPC endpoint in a host Rails app, each call behind the host's own sign-in and hub_kernel-interface's permission check and account scope, with a boot check for hubs that cannot be served as tools, the OAuth discovery documents, 401 challenge and client registration that let a client such as Claude's connector screen find the endpoint's sign-in and register by itself, with each client recording the address it registered from and a configurable limit on how many clients one address may register per hour past which registration is refused, and the approval page where a person signed in to the host through the browser approves that client and is issued an authorization code, the token exchange that trades that code with its PKCE verifier for an access token lasting an hour and a refresh token, the refresh exchange that trades a refresh token for a new access token and a new refresh token and retires the one posted, and the lookup a host's sign-in calls to get the person a bearer token acts for, which records when the connection was last used, and the settings section a host registers with settings_hub that lists a person's connections with the app's name, when it connected and when it was last used, and disconnects one that is the person's own, and the prune task a host schedules with its own job runner that removes expired authorization codes, connections whose access and refresh tokens have both expired, and clients over a day old with no connection
6
6
  ---
7
7
 
8
8
  This local writes the code or configuration that talks to a host's hub_kernel-mcp
@@ -12,15 +12,23 @@ the developer and does not pick.
12
12
  ## What hub_kernel-mcp is
13
13
 
14
14
  A JSON-RPC endpoint in a host Rails app that offers every method the host's hubs
15
- expose as an MCP tool, for the person and account the host's sign-in gives.
16
- Fire this local when code or a client needs to list or call those tools, or when
17
- a test needs to send requests to the endpoint.
15
+ expose as an MCP tool, for the person and account the host's sign-in gives. Beside
16
+ it, the host serves two discovery documents, a registration address, a browser
17
+ approval page and a token address, so a client such as Claude's connector screen
18
+ can find the endpoint's sign-in, register itself, have a person approve it, trade
19
+ the approval's code for an access token it sends on every request, and trade a
20
+ refresh token for a new access token when that one expires, from the endpoint's
21
+ address alone. Each approval the person gives is kept as a connection, which the
22
+ host's own code can read. Fire this local when code or a client needs to list or
23
+ call those tools, find or register with the endpoint's sign-in, send a person to
24
+ approve a client, trade a code or a refresh token for tokens, read a person's
25
+ connections, or when a test needs to send requests to any of these addresses.
18
26
 
19
27
  ## Interface
20
28
 
21
- - `POST /` — the endpoint, at the path the host serves it under. It takes one
22
- JSON-RPC 2.0 request per POST as a JSON body. A GET to the same path is
23
- answered with status 405 and `Allow: POST`.
29
+ - `POST /` — the endpoint, at the path the host serves it under, such as `/mcp`.
30
+ It takes one JSON-RPC 2.0 request per POST as a JSON body. A GET to the same
31
+ path is answered with status 405 and `Allow: POST`.
24
32
  - `initialize` — answers `protocolVersion`, `serverInfo` with name
25
33
  `hub_kernel-mcp` and the gem's version, and `capabilities: { tools: {} }`.
26
34
  `protocolVersion` is the one the client sent in `params.protocolVersion` when
@@ -32,121 +40,409 @@ a test needs to send requests to the endpoint.
32
40
  - `tools/call` — runs the hub method a tool names with `params.arguments`, for
33
41
  the signed-in person and account, and answers the method's return value as
34
42
  JSON text.
43
+ - `POST /register` — registers a client under the endpoint's path, such as
44
+ `/mcp/register`, from its `client_name` and `redirect_uris`, and answers its
45
+ `client_id`. It needs no sign-in. A refusal answers `error` and
46
+ `error_description`. One network address may register only as many clients
47
+ an hour as the host's limit allows, ten unless the host set another.
48
+ - `GET /authorize` — the approval page under the endpoint's path, such as
49
+ `/mcp/authorize`, opened in a person's browser. It sends a person who is not
50
+ signed in to the host through the host's sign-in, then shows the client's name
51
+ with an Approve and a Deny button. A request it cannot send back to the client
52
+ shows a page whose heading is the reason.
53
+ - `POST /authorize` — the approval page's answer. It sends the browser back to
54
+ the client's redirect address with a `code` and the client's `state` on
55
+ Approve, and with `error=access_denied` and the `state` on Deny.
56
+ - `POST /token` — the token address under the endpoint's path, such as
57
+ `/mcp/token`. It trades an approval's `code`, with the `redirect_uri` it was
58
+ approved for and the PKCE `code_verifier`, for an access token lasting an hour
59
+ and a refresh token. It needs no sign-in. A refusal answers `error` and
60
+ `error_description`.
61
+ - `grant_type=refresh_token` — a `POST /token` that trades a refresh token and
62
+ the `client_id` it was issued to for a new access token and a new refresh
63
+ token, and stops the posted refresh token and the access token issued with it
64
+ from working.
65
+ - `WWW-Authenticate` — the header on every 401 from the endpoint's path, naming
66
+ the address of the endpoint's protected-resource document.
67
+ - `GET /.well-known/oauth-protected-resource` — answers the endpoint's address
68
+ and its sign-in's address, the site root. It answers the same document with
69
+ any path after it, such as `/.well-known/oauth-protected-resource/mcp`.
70
+ - `GET /.well-known/oauth-authorization-server` — answers the sign-in's
71
+ document: its registration, approval and token addresses, and that a client
72
+ must use PKCE with SHA-256.
73
+ - `HubKernel::Mcp::Connection.of` — takes a person record and answers an Active
74
+ Record relation of that person's connections, one per approval they gave,
75
+ each with its app's `client.name`, `created_at` and `last_used_at`.
35
76
 
36
77
  ## How to use it
37
78
 
38
79
  1. Find the path the host serves the endpoint at in its `config/routes.rb`. If
39
80
  it is not there, stop and tell the developer the endpoint is not installed
40
- yet.
81
+ yet. For steps 3 to 8, also check that the routes serve the discovery
82
+ documents at `/.well-known`. If they do not, stop and tell the developer the
83
+ discovery documents are not installed yet.
41
84
 
42
- 2. Ask the developer how the client signs in. Every request runs the host's own
43
- sign-in first, so the client must send whatever that sign-in expects, such as
44
- a session cookie or a token header. A request the sign-in refuses gets the
45
- host's refusal, for example status 401, and no hub is asked. Do not choose the
46
- credential yourself.
85
+ 2. Ask the developer how the client signs in. There are two ways, and the choice
86
+ is theirs:
47
87
 
48
- 3. Send every request as a POST with a JSON body holding exactly one request:
88
+ - The client sends the host's own credential, such as a session cookie or a
89
+ token header, with every request. Skip to step 9.
90
+ - The client finds the sign-in by itself, registers, has a person approve
91
+ it, trades the approval for an access token, and refreshes that token, as
92
+ Claude's connector screen does. Follow steps 3 to 8.
93
+
94
+ 3. Find the sign-in from a refused request. Send any request to the endpoint
95
+ without a credential. When the host's sign-in refuses it with status 401, the
96
+ answer carries this header, built from the site's address and the endpoint's
97
+ path:
49
98
 
50
- ```json
51
- { "jsonrpc": "2.0", "id": 1, "method": "tools/list" }
99
+ ```
100
+ WWW-Authenticate: Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource/mcp"
52
101
  ```
53
102
 
54
- `jsonrpc` must be `"2.0"` and `method` must be a string. A batch, a JSON array
55
- of requests, is refused with -32600. A request with no `id` is a
56
- notification: it is answered with status 202 and no body, and nothing is run.
103
+ The header is added only to a 401. A host whose sign-in refuses with another
104
+ status, such as a redirect to a sign-in page, sends no header, so tell the
105
+ developer the client cannot discover the sign-in on that host.
106
+
107
+ 4. Read the two discovery documents. `GET` the address in `resource_metadata`:
57
108
 
58
- 4. Send `initialize` first. Ask the developer which protocol version the client
59
- supports, send it as `params.protocolVersion`, and use the version the answer
60
- gives. Then send the `notifications/initialized` notification with no `id`.
109
+ ```json
110
+ { "resource": "https://example.com/mcp", "authorization_servers": [ "https://example.com" ] }
111
+ ```
61
112
 
62
- 5. Send `tools/list` to get the tools. Each tool has this shape:
113
+ Then `GET /.well-known/oauth-authorization-server` on the address in
114
+ `authorization_servers`:
63
115
 
64
116
  ```json
65
117
  {
66
- "name": "supplies__price_of",
67
- "description": "Reads data from the supplies hub.",
68
- "inputSchema": { "type": "object", "properties": { "item": {} } },
69
- "annotations": { "readOnlyHint": true }
118
+ "issuer": "https://example.com",
119
+ "registration_endpoint": "https://example.com/mcp/register",
120
+ "authorization_endpoint": "https://example.com/mcp/authorize",
121
+ "token_endpoint": "https://example.com/mcp/token",
122
+ "response_types_supported": [ "code" ],
123
+ "grant_types_supported": [ "authorization_code", "refresh_token" ],
124
+ "token_endpoint_auth_methods_supported": [ "none" ],
125
+ "code_challenge_methods_supported": [ "S256" ]
70
126
  }
71
127
  ```
72
128
 
73
- `name` is `<served name>__<method>`, split at the first two underscores in a
74
- row. `inputSchema.properties` names each value the method takes and gives no
75
- type for any of them. A method that writes has `readOnlyHint: false` and the
76
- description `Changes data in the <served name> hub.` The list depends on who
77
- is signed in and in which account, so list again after either changes.
129
+ Take every address from these documents, never build one by hand. A client
130
+ must use the authorization code grant with a PKCE `S256` challenge, and it
131
+ holds no client secret.
78
132
 
79
- 6. Ask the developer whether the client must confirm with the person before it
80
- calls a tool whose `readOnlyHint` is false. If it must, add that confirmation
81
- before step 7.
133
+ The discovery documents, the registration address and the token address all
134
+ answer an unexpected error with status 500 and this body, which never carries
135
+ the error's own message, so find the cause in the host app's error reporting:
82
136
 
83
- 7. Send `tools/call` with the tool's name and its values:
137
+ ```json
138
+ { "error": "server_error", "error_description": "The sign-in failed unexpectedly" }
139
+ ```
140
+
141
+ 5. Register the client. Ask the developer for the client's name and every
142
+ redirect address it will use, since both belong to the client. Each redirect
143
+ address must be HTTPS, or plain HTTP on `localhost`, `127.0.0.1` or `::1`.
144
+ POST them as JSON to `registration_endpoint`, with no credential:
145
+
146
+ ```json
147
+ { "client_name": "Claude", "redirect_uris": [ "https://claude.ai/api/mcp/auth_callback" ] }
148
+ ```
149
+
150
+ A registration is answered with status 201:
84
151
 
85
152
  ```json
86
153
  {
87
- "jsonrpc": "2.0",
88
- "id": 2,
89
- "method": "tools/call",
90
- "params": { "name": "supplies__price_of", "arguments": { "item": "soap" } }
154
+ "client_id": "<generated id>",
155
+ "client_name": "Claude",
156
+ "redirect_uris": [ "https://claude.ai/api/mcp/auth_callback" ],
157
+ "token_endpoint_auth_method": "none"
91
158
  }
92
159
  ```
93
160
 
94
- Send only the values named in that tool's `inputSchema.properties`. A
95
- successful call answers:
161
+ Keep `client_id`, and send the person to the approval page in step 6 soon
162
+ after. A host that runs its prune task removes a client over a day old that
163
+ has no connection and no unexpired code, and a removed `client_id` is refused
164
+ everywhere it is sent. A registration is refused with one of these:
96
165
 
97
- ```json
98
- { "content": [ { "type": "text", "text": "\"soap costs 3\"" } ], "isError": false }
166
+ | Status | `error` | `error_description` | When |
167
+ |---|---|---|---|
168
+ | 400 | `invalid_redirect_uri` | `A client must register at least one redirect address` | `redirect_uris` is missing or empty. |
169
+ | 400 | `invalid_redirect_uri` | `<address> is neither HTTPS nor on the client's own machine`, one per address refused | An address is not allowed. |
170
+ | 400 | `invalid_client_metadata` | `The registration body is not valid JSON` | The body could not be read as JSON. |
171
+ | 429 | `too_many_registrations` | `This address has registered <limit> clients in the last hour, which is the limit` | The network address the request came from has already registered the host's limit of clients, ten unless the host set another, in the last hour. |
172
+
173
+ Show `error_description` to the developer and do not retry the same body.
174
+ Register once and reuse that `client_id` for every approval, since every
175
+ registration from the address counts toward the limit whether or not a
176
+ person approves it. After a 429, registration from that address works again
177
+ once fewer than the limit of its registrations are under an hour old.
178
+
179
+ 6. Send the person to the approval page. Make a fresh random PKCE verifier and a
180
+ fresh random `state` for this attempt, and keep both. The challenge is the
181
+ verifier's SHA-256 digest, base64url-encoded with no padding. Open
182
+ `authorization_endpoint` in the person's browser with these query values:
183
+
184
+ ```
185
+ https://example.com/mcp/authorize?response_type=code&client_id=<client_id>&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback&state=<state>&code_challenge=<challenge>&code_challenge_method=S256
99
186
  ```
100
187
 
101
- `text` is the method's return value encoded as JSON, so parse it as JSON to
102
- get the value back.
188
+ `redirect_uri` must be exactly one of the addresses registered in step 5.
189
+ The page runs the host's own sign-in first, then shows the client's name and
190
+ an Approve and a Deny button. The browser comes back to `redirect_uri` with
191
+ the client's `state` added to any query the address already has:
192
+
193
+ | Query back | When |
194
+ |---|---|
195
+ | `code=<code>&state=<state>` | The person approved. |
196
+ | `error=access_denied&state=<state>` | The person denied. No code is sent. |
197
+ | `error=invalid_request&error_description=A+PKCE+challenge+using+S256+is+required&state=<state>` | `code_challenge` was missing, or `code_challenge_method` was not `S256`. |
198
+
199
+ Refuse any answer whose `state` is not the one sent. A code lasts 10 minutes
200
+ and is tied to the person who approved, the client, the `redirect_uri` and
201
+ the challenge.
202
+
203
+ When the page cannot trust the redirect address, it does not send the
204
+ browser back. It shows the person a page whose heading is the reason, after
205
+ the host's sign-in, so the client hears nothing back:
103
206
 
104
- 8. Handle a tool error. These come back as a normal result with `isError: true`
105
- and the reason as the text:
207
+ | Status | Heading | When |
208
+ |---|---|---|
209
+ | 400 | `The approval request is missing client_id` | `client_id` is missing. |
210
+ | 400 | `The approval request is missing redirect_uri` | `redirect_uri` is missing. |
211
+ | 400 | `The app asking to connect is not registered` | The host never registered that `client_id`, or its prune task removed it. |
212
+ | 400 | `This app asked to send you to an address it did not register` | `redirect_uri` is not one registered for that client. |
213
+ | 500 | `Signing in failed unexpectedly` | The page raised an unexpected error. |
106
214
 
107
- - The hub refused the call.
108
- - A value the method requires is missing.
109
- - A value the method is not listed with was sent, such as
110
- `price_of does not take colour`.
111
- - A record the call names does not exist, such as `No item has the id 9`.
215
+ Tell the developer to check the client's registered addresses and the query
216
+ it builds when a person reports one of these pages. The 500 page never
217
+ carries the error's own message, so find the cause in the host app's error
218
+ reporting.
112
219
 
113
- Show the reason to the person or the model making the call. Do not retry it
114
- unchanged.
220
+ 7. Trade the code for an access token. POST it form-encoded to `token_endpoint`,
221
+ with no credential, along with the verifier from step 6 and the same
222
+ `redirect_uri`:
115
223
 
116
- 9. Handle a JSON-RPC error. These come back as `error: { code, message }` with
117
- status 200, and with the request's `id` except for -32700 and -32600, whose
118
- `id` is null:
224
+ ```
225
+ grant_type=authorization_code&code=<code>&code_verifier=<verifier>&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback&client_id=<client_id>
226
+ ```
119
227
 
120
- | Code | Message | When |
228
+ A trade is answered with status 200:
229
+
230
+ ```json
231
+ { "access_token": "<token>", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "<refresh token>" }
232
+ ```
233
+
234
+ Keep `access_token` and `refresh_token` as secrets, since both act for the
235
+ person who approved. The token address refuses with status 400 and gives no
236
+ token:
237
+
238
+ | `error` | `error_description` | When |
121
239
  |---|---|---|
122
- | -32700 | `Parse error` | The body is not valid JSON. |
123
- | -32600 | `Invalid Request` | The body is not one JSON-RPC 2.0 request. |
124
- | -32601 | `Method not found: <method>` | The MCP method is not one of the four above. |
125
- | -32602 | `Unknown tool: <name>` | The tool does not exist, names a hub the host does not serve, or is one the caller may not call. |
126
- | -32603 | `Internal error` | A hub method raised an unexpected error. |
240
+ | `invalid_grant` | `The code is unknown, used, expired, or does not match this client, redirect address or verifier` | The code is unknown, more than 10 minutes old, already traded, sent with a `client_id` other than the one it was approved for, sent with a `redirect_uri` other than the one approved, or sent with a verifier whose SHA-256 digest is not the challenge. |
241
+ | `unsupported_grant_type` | `The token address takes authorization_code or refresh_token` | `grant_type` is missing or is any other value. |
242
+ | `invalid_request` | `The token request body could not be read` | The body could not be parsed, such as malformed JSON. |
127
243
 
128
- -32602 reads the same in all three cases, so do not tell the person a tool
129
- does not exist when it may only be one they cannot call. -32603 never carries
130
- the error's own message, so find the cause in the host app's error reporting.
244
+ A code is used up only by a trade that gives a token. A code posted again
245
+ after it was traded is refused, and the access token and refresh token the
246
+ first trade gave stop working, so trade each code exactly once.
131
247
 
132
- 10. In a test of the host app, send requests the same way, as a JSON POST to the
133
- endpoint's path, signed in as the host's tests sign in:
248
+ 8. Refresh the access token when it expires after an hour. POST form-encoded to
249
+ `token_endpoint`, with no credential, the refresh token last issued and the
250
+ `client_id` from step 5:
251
+
252
+ ```
253
+ grant_type=refresh_token&refresh_token=<refresh token>&client_id=<client_id>
254
+ ```
255
+
256
+ A refresh is answered with status 200 and the same shape as step 7, holding a
257
+ new `access_token` and a new `refresh_token`. Replace both stored tokens with
258
+ the new ones at once. The refresh token posted stops working, and so does the
259
+ access token issued with it, even if its hour has not passed. A refresh token
260
+ lasts 90 days from when it was issued, and each refresh issues one that lasts
261
+ another 90 days.
262
+
263
+ A refresh is answered with status 400 and this body, and gives no token, when the refresh token is unknown, already used, more than 90
264
+ days old, sent with a `client_id` other than the one it was issued to, or
265
+ belongs to a connection the person has disconnected:
266
+
267
+ ```json
268
+ { "error": "invalid_grant", "error_description": "The refresh token is unknown, used, unused for ninety days, or belongs to another client" }
269
+ ```
270
+
271
+ Of two refreshes sent at once with the same refresh token, only one is answered
272
+ with tokens. After an `invalid_grant`, go back to step 5 and register again,
273
+ then to step 6 with a fresh verifier and `state`. The connection may have
274
+ been removed, and once a client has had no connection for over a day the
275
+ host's prune task removes the client too, and its old `client_id` is then
276
+ refused on the approval page without the client hearing back.
277
+
278
+ 9. Send every request to the endpoint as a POST with a JSON body holding exactly
279
+ one request, with the credential step 2 settled on. A client that followed
280
+ steps 3 to 8 sends its access token as `Authorization: Bearer <access_token>`:
281
+
282
+ ```json
283
+ { "jsonrpc": "2.0", "id": 1, "method": "tools/list" }
284
+ ```
285
+
286
+ `jsonrpc` must be `"2.0"` and `method` must be a string. A batch, a JSON array
287
+ of requests, is refused with -32600. A request with no `id` is a
288
+ notification: it is answered with status 202 and no body, and nothing is run.
289
+ A request the sign-in refuses gets the host's refusal, and no hub is asked. A
290
+ 401 for an access token means it has expired, was replaced by a refresh, was
291
+ disconnected by the person, or is unknown, so refresh it as in step 8.
292
+
293
+ 10. Send `initialize` first. Ask the developer which protocol version the client
294
+ supports, send it as `params.protocolVersion`, and use the version the answer
295
+ gives. Then send the `notifications/initialized` notification with no `id`.
296
+
297
+ 11. Send `tools/list` to get the tools. Each tool has this shape:
298
+
299
+ ```json
300
+ {
301
+ "name": "supplies__price_of",
302
+ "description": "Reads data from the supplies hub.",
303
+ "inputSchema": { "type": "object", "properties": { "item": {} } },
304
+ "annotations": { "readOnlyHint": true }
305
+ }
306
+ ```
307
+
308
+ `name` is `<served name>__<method>`, split at the first two underscores in a
309
+ row. `inputSchema.properties` names each value the method takes and gives no
310
+ type for any of them. A method that writes has `readOnlyHint: false` and the
311
+ description `Changes data in the <served name> hub.` The list depends on who
312
+ is signed in and in which account, so list again after either changes.
313
+
314
+ 12. Ask the developer whether the client must confirm with the person before it
315
+ calls a tool whose `readOnlyHint` is false. If it must, add that confirmation
316
+ before step 13.
317
+
318
+ 13. Send `tools/call` with the tool's name and its values:
319
+
320
+ ```json
321
+ {
322
+ "jsonrpc": "2.0",
323
+ "id": 2,
324
+ "method": "tools/call",
325
+ "params": { "name": "supplies__price_of", "arguments": { "item": "soap" } }
326
+ }
327
+ ```
328
+
329
+ Send only the values named in that tool's `inputSchema.properties`. A
330
+ successful call answers:
331
+
332
+ ```json
333
+ { "content": [ { "type": "text", "text": "\"soap costs 3\"" } ], "isError": false }
334
+ ```
335
+
336
+ `text` is the method's return value encoded as JSON, so parse it as JSON to
337
+ get the value back.
338
+
339
+ 14. Handle a tool error. These come back as a normal result with `isError: true`
340
+ and the reason as the text:
341
+
342
+ - The hub refused the call.
343
+ - A value the method requires is missing.
344
+ - A value the method is not listed with was sent, such as
345
+ `price_of does not take colour`.
346
+ - A record the call names does not exist, such as `No item has the id 9`.
347
+
348
+ Show the reason to the person or the model making the call. Do not retry it
349
+ unchanged.
350
+
351
+ 15. Handle a JSON-RPC error. These come back as `error: { code, message }` with
352
+ status 200, and with the request's `id` except for -32700 and -32600, whose
353
+ `id` is null:
354
+
355
+ | Code | Message | When |
356
+ |---|---|---|
357
+ | -32700 | `Parse error` | The body is not valid JSON. |
358
+ | -32600 | `Invalid Request` | The body is not one JSON-RPC 2.0 request. |
359
+ | -32601 | `Method not found: <method>` | The MCP method is not one of the four above. |
360
+ | -32602 | `Unknown tool: <name>` | The tool does not exist, names a hub the host does not serve, or is one the caller may not call. |
361
+ | -32603 | `Internal error` | A hub method raised an unexpected error. |
362
+
363
+ -32602 reads the same in all three cases, so do not tell the person a tool
364
+ does not exist when it may only be one they cannot call. -32603 never carries
365
+ the error's own message, so find the cause in the host app's error reporting.
366
+
367
+ 16. To read the apps a person has connected from the host's own code, call
368
+ `HubKernel::Mcp::Connection.of` with the person record the host's sign-in
369
+ gives. It answers a relation, so chain `includes`, `order` or `where` onto
370
+ it:
371
+
372
+ ```ruby
373
+ HubKernel::Mcp::Connection.of(person).includes(:client).order(:created_at).each do |connection|
374
+ connection.client.name # the name the app registered with
375
+ connection.created_at # when the person approved it
376
+ connection.last_used_at # when its access token last signed a request in, or nil if never
377
+ end
378
+ ```
379
+
380
+ There is one connection per approval, so an app approved twice is listed
381
+ twice. A refresh keeps the same connection and its `created_at`. A
382
+ connection stays listed after its access token expires. It leaves the list
383
+ when the person disconnects it, or when its access and refresh tokens have
384
+ both expired and the host's prune task runs. `last_used_at` is set each time the host's sign-in accepts
385
+ the connection's access token. Ask the developer whether a list they build
386
+ should show expired connections. To show the list on a settings page with
387
+ Disconnect buttons, use the settings section `hub_kernel-mcp-install` covers
388
+ instead of building one.
389
+
390
+ 17. In a test of the host app, send requests the same way: a JSON POST to the
391
+ endpoint's path, signed in as the host's tests sign in, plain GETs and POSTs
392
+ to the discovery and registration addresses, the approval page's GET and
393
+ POST signed in as the host's browser tests sign in, with the same query
394
+ values as step 6 and `decision` set to `approve` or `deny` on the POST, a
395
+ form POST to the token address with the code the approval sent back, and a
396
+ form POST to the token address with the refresh token that trade gave:
134
397
 
135
398
  ```ruby
136
399
  post "/mcp", params: { jsonrpc: "2.0", id: 1, method: "tools/list" }, as: :json
400
+ get "/.well-known/oauth-protected-resource/mcp"
401
+ post "/mcp/register", params: { client_name: "Claude", redirect_uris: [ "https://claude.ai/api/mcp/auth_callback" ] }, as: :json
402
+ get "/mcp/authorize", params: approval
403
+ post "/mcp/authorize", params: approval.merge(decision: "approve")
404
+ post "/mcp/token", params: { grant_type: "authorization_code", code: code, code_verifier: verifier, redirect_uri: approval[:redirect_uri], client_id: approval[:client_id] }
405
+ post "/mcp/token", params: { grant_type: "refresh_token", refresh_token: refresh_token, client_id: approval[:client_id] }
406
+ post "/mcp", params: { jsonrpc: "2.0", id: 1, method: "tools/list" }, as: :json, headers: { "Authorization" => "Bearer #{access_token}" }
137
407
  ```
138
408
 
409
+ `approval` holds `response_type`, `client_id`, `redirect_uri`, `state`,
410
+ `code_challenge` and `code_challenge_method` for a client registered in the
411
+ test. `code` is read from the `code` query value of the approval POST's
412
+ redirect, and `access_token` and `refresh_token` from a token POST's JSON
413
+ answer.
414
+
139
415
  ## Conventions
140
416
 
141
- - One request per POST, always with `"jsonrpc": "2.0"`, and an `id` on every
142
- request that needs an answer.
417
+ - One request per POST to the endpoint, always with `"jsonrpc": "2.0"`, and an
418
+ `id` on every request that needs an answer.
143
419
  - Build tool names from `tools/list`, never by hand, since the list is what the
144
420
  signed-in person may call.
421
+ - Build sign-in addresses from the `WWW-Authenticate` header and the two
422
+ discovery documents, never by hand.
423
+ - Send the approval page a fresh `state` and PKCE challenge on every attempt,
424
+ check the `state` that comes back, and trade the code with that attempt's
425
+ verifier and `redirect_uri`.
145
426
  - Treat `isError: true` as an answer about the call, and a JSON-RPC `error` as an
146
427
  answer about the request.
428
+ - Registration needs no sign-in and holds no secret, so a `client_id` alone is
429
+ never proof of who is calling.
430
+ - An access token lasts one hour. Refresh it with the latest refresh token and
431
+ store both new tokens, since every refresh retires the pair it replaced.
432
+ - Send a person back to the approval page only after a refresh is refused with
433
+ `invalid_grant`, and register again first.
434
+ - Reuse a client's `client_id` for every approval until a refresh is refused,
435
+ since registrations from one network address are limited per hour.
436
+ - Show a sign-in refusal's `error_description` to the developer, and never retry
437
+ a refused request unchanged.
147
438
  - The endpoint offers no sessions and no server-sent events, so do not open a GET
148
439
  stream or send a session header.
149
- - Out of scope: installing the endpoint, choosing its controller, sign-in, and
150
- served hubs, and running the boot check, which belong to
440
+ - Out of scope: installing the endpoint, mounting the discovery documents,
441
+ installing the client, authorization code and connection migrations, choosing
442
+ the endpoint's controller, sign-in, and served hubs, having the host's sign-in
443
+ accept an access token, naming the approval page's controller, sign-in, person
444
+ and layout, running the boot check, and registering the connections settings
445
+ section and its Disconnect action, setting the registration limit, and
446
+ scheduling the prune task, which belong to
151
447
  `hub_kernel-mcp-install`, and setting which methods a hub exposes, who may
152
448
  call them and their account scope, which belong to hub_kernel-interface.