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
data/lib/hub_kernel/mcp.rb
CHANGED
|
@@ -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
|
|
@@ -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,
|
|
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
|
-
|
|
17
|
-
a
|
|
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
|
|
22
|
-
JSON-RPC 2.0 request per POST as a JSON body. A GET to the same
|
|
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.
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
51
|
-
|
|
99
|
+
```
|
|
100
|
+
WWW-Authenticate: Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource/mcp"
|
|
52
101
|
```
|
|
53
102
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
109
|
+
```json
|
|
110
|
+
{ "resource": "https://example.com/mcp", "authorization_servers": [ "https://example.com" ] }
|
|
111
|
+
```
|
|
61
112
|
|
|
62
|
-
|
|
113
|
+
Then `GET /.well-known/oauth-authorization-server` on the address in
|
|
114
|
+
`authorization_servers`:
|
|
63
115
|
|
|
64
116
|
```json
|
|
65
117
|
{
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
88
|
-
"
|
|
89
|
-
"
|
|
90
|
-
"
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
`
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
123
|
-
|
|
|
124
|
-
|
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
|
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,
|
|
150
|
-
|
|
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.
|