Skip to content

Commit b95b694

Browse files
Maselkovclaude
andcommitted
MCP: sign in from claude.ai with OAuth
claude.ai's connectors only sign in with OAuth, so the server is now its own authorization server for /mcp: - /mcp's 401 points at the protected resource metadata (RFC 9728), which names this server's authorization server metadata (RFC 8414). - Clients register themselves (RFC 7591, public clients only, unused ones dropped after a day, capped per hour). - /oauth/authorize is a consent page: sign in with your access token, see which app asks and where it sends you back, Allow or Cancel. Its own CSP lets the form's redirect reach the app's callback. - /oauth/token trades the code (PKCE S256 only) for an hour's access token and a 30-day refresh token. Refresh tokens rotate; one used twice revokes the grant. - Loopback redirect URIs match on any port, for Claude Code. - Manage users lists each user's connected apps with Disconnect. Revoking the login token they consented with, New token or deleting the user disconnects them too. - docs: using.md (claude.ai and Claude Code setup), server.md (PUBLIC_URL, proxy paths), CONTEXT.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent e54e11f commit b95b694

18 files changed

Lines changed: 1305 additions & 23 deletions

File tree

‎docs/CONTEXT.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,26 @@ no icons, capped lists, ISO times). Auth is `auth.bearer_required`: the
795795
user's personal access token in an `Authorization: Bearer` header,
796796
never a session cookie, so a browser can't be tricked into calling it.
797797

798+
**The server is its own OAuth server, for claude.ai.** claude.ai's
799+
connectors only sign in with OAuth, so `/mcp` (gcm/oauth.py,
800+
routes/oauth.py, store/oauth.py) answers 401 with a pointer to its
801+
protected resource metadata (RFC 9728), which names this server as the
802+
authorization server (RFC 8414). Clients register themselves (RFC 7591,
803+
public clients only; Claude registers on every new connection, so unused
804+
registrations are dropped after a day and capped per hour). A Client ID
805+
Metadata Document isn't supported: Claude falls back to registering when
806+
the metadata doesn't advertise one. The consent page signs in with the
807+
user's existing access token, so there's no new credential; it's plain
808+
HTML with its own CSP, whose `form-action` allows the app's callback
809+
origin, as Chrome applies `form-action` to the redirect after a POST.
810+
PKCE is S256 only. Loopback redirect URIs match on any port (RFC 8252),
811+
as Claude Code picks a free port each time. Access tokens last an hour
812+
and refresh tokens 30 days; refresh tokens rotate, and one used twice
813+
revokes its grant (it was copied). A grant remembers the login token the
814+
user consented with, so revoking that token, **New token** or deleting
815+
the user ends it too. `PUBLIC_URL`, when set, is the issuer and resource
816+
URL: behind a proxy the request's own host may not be what clients use.
817+
798818
**Progress % counts steps, not time.** The server computes it
799819
(`gcm/progress.py`, called from `tracking.process_jobs`), not
800820
craft_monitor.lua. A step is one item or fluid type in the job's

‎docs/server.md‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,11 @@ which is only appropriate on a trusted LAN. To expose it to the internet:
140140
5. Use an `https://` `SERVER_URL` in `/home/config.lua` so the API key isn't sent
141141
in cleartext.
142142

143-
Proxy the whole site, not just the page: AI assistants use `/mcp` (see
144-
[AI assistants](using.md#ai-assistants)).
143+
Proxy the whole site, not just the page: AI assistants use `/mcp`,
144+
`/oauth/...` and `/.well-known/...` (see
145+
[AI assistants](using.md#ai-assistants)). Set `PUBLIC_URL` to the address
146+
people use, e.g. `https://monitor.example.com`: an assistant app is told to
147+
sign in there, and claude.ai only accepts an `https://` one.
145148

146149
The server accepts up to 500 open connections. A proxy keeps idle
147150
connections to it open for reuse, and opening a page full of icons can make
@@ -189,7 +192,11 @@ Managing users:
189192
the item, whether it started or failed (and why), and which CPU.
190193
**Stock refills** adds the requests their keep-in-stock targets made;
191194
the server makes those, under whoever last set the target.
192-
- **Revoke** invalidates a token and ends its sessions immediately.
195+
- **Revoke** invalidates a token and ends its sessions immediately, and
196+
disconnects any AI assistant the user connected while signed in with it.
197+
- **App: ...** rows are AI assistants the user connected
198+
([AI assistants](using.md#ai-assistants)); **Disconnect** stops one at
199+
once.
193200
- **New token** replaces a user's current token in one step.
194201
- **Delete** (not on your own account) permanently removes a user with their tokens, sessions,
195202
pins and pending notifications. Their craft request history is kept.

‎docs/using.md‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -448,21 +448,40 @@ draining fastest this week?", "why is the craft on M00 slow?", "what's
448448
missing to make 64 of these?". Its address is your page's address with
449449
`/mcp` on the end, e.g. `https://monitor.example.com/mcp`.
450450

451-
The assistant signs in with your personal access token, so it sees what you
451+
The assistant acts as you: it signs in with your account and sees what you
452452
see. It can't change anything yet: every tool reads.
453453

454-
With [Claude Code](https://claude.com/claude-code):
454+
**claude.ai, the Claude apps, or another app that connects by URL:** add a
455+
custom connector with the `/mcp` address. The app sends you to this server's
456+
**Connect** page: sign in with your access token if you aren't already,
457+
check which app is asking, and click **Allow**. The server must be reachable
458+
from the internet over HTTPS for claude.ai (see
459+
[Exposing it beyond your LAN](server.md#exposing-it-beyond-your-lan)); one
460+
on your LAN only works with apps running on your own computer.
461+
462+
**[Claude Code](https://claude.com/claude-code):** add the server, then run
463+
`/mcp` in Claude Code and pick it to sign in the same way:
464+
465+
```bash
466+
claude mcp add --transport http gtnh https://monitor.example.com/mcp
467+
```
468+
469+
Or skip the sign-in page and give it your access token, as with any MCP
470+
client that can send a header:
455471

456472
```bash
457473
claude mcp add --transport http gtnh https://monitor.example.com/mcp \
458474
--header "Authorization: Bearer gcm_tok-..."
459475
```
460476

461-
Other MCP clients that can send a header work the same way: the endpoint
462-
speaks Streamable HTTP and wants `Authorization: Bearer <your token>`.
463477
Anyone holding the token can use it as you, so keep it out of shared config
464478
files, and use **New token** in Manage users if it leaks.
465479

480+
An app connected through the Connect page shows under its user in
481+
**Manage users** ("App: Claude · connected Nov 4 · used 5 minutes ago"),
482+
where an admin can **Disconnect** it. Revoking the token you signed in with,
483+
or **New token**, disconnects it too.
484+
466485
What the assistant can look at:
467486

468487
| Tool | What it answers |

‎server/gcm/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
agents, auth, backup, bottlenecks, charts, commands, config, db, gamedata, gamedata_check, inventory, job_tree, lenient_json, patterns,
88
planner, push, recipes, retention, security, state, store, webhooks,
99
)
10-
from gcm.routes import agent, craft_requests, crafts, mcp, network, pages, power, users
10+
from gcm.routes import agent, craft_requests, crafts, mcp, network, oauth, pages, power, users
1111
from gcm.routes import backup as backup_routes
1212
from gcm.routes import gamedata as gamedata_routes
1313
from gcm.routes import push as push_routes
@@ -45,6 +45,7 @@ def create_app(data_dir=None, api_key=None):
4545
agent.bp,
4646
backup_routes.bp,
4747
mcp.bp,
48+
oauth.bp,
4849
pages.bp,
4950
):
5051
app.register_blueprint(blueprint)

‎server/gcm/auth.py‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727

2828
from flask import g, jsonify, request
2929

30-
from gcm import config, store
30+
from gcm import config, oauth, store
3131

3232

3333
def _api_key_valid():
@@ -145,21 +145,34 @@ def _check_login():
145145
return None
146146

147147

148+
def bearer_token():
149+
"""The token in an Authorization: Bearer header, or None."""
150+
scheme, _, token = request.headers.get("Authorization", "").partition(" ")
151+
token = token.strip()
152+
return token if scheme.lower() == "bearer" and token else None
153+
154+
148155
def bearer_user():
149-
"""The user an Authorization: Bearer header's access token signs in,
150-
or None."""
151-
header = request.headers.get("Authorization", "")
152-
scheme, _, token = header.partition(" ")
153-
if scheme.lower() != "bearer" or not token.strip():
156+
"""The user a bearer token acts for, or None: an OAuth access token
157+
an app was given (gcm/oauth.py), or the user's own access token."""
158+
token = bearer_token()
159+
if token is None:
154160
return None
155-
return store.users.token_user(token.strip())
161+
if token.startswith(store.oauth.ACCESS_PREFIX):
162+
return store.oauth.access_user(token)
163+
return store.users.token_user(token)
156164

157165

158166
def _check_bearer():
159167
g.user = bearer_user()
160168
if not g.user:
169+
# Where an MCP client finds out how to sign in (RFC 9728): a
170+
# 401 must say, claude.ai ignores it on anything else.
171+
challenge = f'Bearer resource_metadata="{oauth.base_url()}/.well-known/oauth-protected-resource"'
172+
if bearer_token() is not None:
173+
challenge += ', error="invalid_token"'
161174
response = jsonify({"error": "authentication required"})
162-
response.headers["WWW-Authenticate"] = 'Bearer realm="gtnh-craft-monitor"'
175+
response.headers["WWW-Authenticate"] = challenge
163176
return response, 401
164177
return None
165178

‎server/gcm/db.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,56 @@ def _app_cancel_items(conn):
631631
conn.execute("ALTER TABLE craft_cancel_history ADD COLUMN item_icon TEXT")
632632

633633

634+
def _app_oauth(conn):
635+
# OAuth for AI assistants' MCP connections (gcm/oauth.py,
636+
# gcm/store/oauth.py). A client registers itself (RFC 7591); a user's
637+
# consent makes a one-use code, and redeeming it a grant: one
638+
# connected app, with the access and refresh tokens issued under it.
639+
# access_token_id is the login token the user consented with, so
640+
# revoking that ends the grant too. Secrets are stored as hashes.
641+
conn.execute("""
642+
CREATE TABLE oauth_clients (
643+
id TEXT PRIMARY KEY,
644+
name TEXT NOT NULL,
645+
redirect_uris TEXT NOT NULL,
646+
created_at REAL NOT NULL
647+
)
648+
""")
649+
conn.execute("""
650+
CREATE TABLE oauth_codes (
651+
code_hash TEXT PRIMARY KEY,
652+
client_id TEXT NOT NULL REFERENCES oauth_clients(id) ON DELETE CASCADE,
653+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
654+
access_token_id TEXT,
655+
redirect_uri TEXT NOT NULL,
656+
code_challenge TEXT NOT NULL,
657+
expires_at REAL NOT NULL
658+
)
659+
""")
660+
conn.execute("""
661+
CREATE TABLE oauth_grants (
662+
id TEXT PRIMARY KEY,
663+
client_id TEXT NOT NULL REFERENCES oauth_clients(id) ON DELETE CASCADE,
664+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
665+
access_token_id TEXT,
666+
created_at REAL NOT NULL,
667+
last_used_at REAL,
668+
revoked_at REAL
669+
)
670+
""")
671+
conn.execute("CREATE INDEX idx_oauth_grants_user ON oauth_grants (user_id)")
672+
conn.execute("""
673+
CREATE TABLE oauth_tokens (
674+
token_hash TEXT PRIMARY KEY,
675+
grant_id TEXT NOT NULL REFERENCES oauth_grants(id) ON DELETE CASCADE,
676+
kind TEXT NOT NULL,
677+
expires_at REAL NOT NULL,
678+
used_at REAL
679+
)
680+
""")
681+
conn.execute("CREATE INDEX idx_oauth_tokens_grant ON oauth_tokens (grant_id)")
682+
683+
634684
APP_MIGRATIONS = [
635685
_app_baseline,
636686
_app_drop_orphaned_user_rows,
@@ -643,6 +693,7 @@ def _app_cancel_items(conn):
643693
_app_settings,
644694
_app_provider_stalls,
645695
_app_cancel_items,
696+
_app_oauth,
646697
]
647698
POWER_MIGRATIONS = [_power_baseline, _power_machines, _power_drop_impossible_times]
648699
ITEM_HISTORY_MIGRATIONS = [

‎server/gcm/oauth.py‎

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""OAuth 2.1 for AI assistants connecting to /mcp, the way the MCP
2+
authorization spec and claude.ai's connectors use it. The server is
3+
both the resource (/mcp) and its own authorization server, signing users
4+
in with their existing access tokens:
5+
6+
1. /mcp answers 401 with a pointer to the protected resource metadata
7+
(RFC 9728), which names this server as the authorization server, and
8+
its metadata (RFC 8414) lists the endpoints below.
9+
2. The client registers itself (RFC 7591): public clients only, no
10+
secret. A Client ID Metadata Document isn't supported, so Claude
11+
falls back to registering.
12+
3. /oauth/authorize shows a consent page (routes/oauth.py); allowing it
13+
sends the browser back to the client with a one-use code, bound to
14+
the PKCE challenge (S256 only).
15+
4. /oauth/token trades the code and the PKCE verifier for an access
16+
token (an hour) and a refresh token (30 days), and a refresh token
17+
for new ones. Refresh tokens rotate: one used twice revokes the
18+
grant. The tokens and grants are kept by gcm/store/oauth.py.
19+
20+
Redirect URIs must be https, or http to a loopback address (a native
21+
client such as Claude Code), where the port is ignored when matching
22+
(RFC 8252 7.3): the client picks a free one each time."""
23+
24+
import base64
25+
import hashlib
26+
import hmac
27+
import ipaddress
28+
from urllib.parse import urlencode, urlsplit, urlunsplit
29+
30+
from flask import request
31+
32+
from gcm import config
33+
34+
MAX_REDIRECT_URIS = 10
35+
MAX_URI_LENGTH = 2000
36+
MAX_CLIENT_NAME = 100
37+
38+
39+
def base_url():
40+
"""This server's address as clients see it: PUBLIC_URL when set (it
41+
must be, behind a proxy that doesn't forward the host), else the
42+
request's own."""
43+
return config.PUBLIC_URL or request.host_url.rstrip("/")
44+
45+
46+
def resource_url():
47+
return base_url() + "/mcp"
48+
49+
50+
def protected_resource_metadata():
51+
return {
52+
"resource": resource_url(),
53+
"authorization_servers": [base_url()],
54+
"bearer_methods_supported": ["header"],
55+
"resource_name": "GTNH Craft Monitor",
56+
}
57+
58+
59+
def authorization_server_metadata():
60+
base = base_url()
61+
return {
62+
"issuer": base,
63+
"authorization_endpoint": base + "/oauth/authorize",
64+
"token_endpoint": base + "/oauth/token",
65+
"registration_endpoint": base + "/oauth/register",
66+
"response_types_supported": ["code"],
67+
"grant_types_supported": ["authorization_code", "refresh_token"],
68+
"code_challenge_methods_supported": ["S256"],
69+
"token_endpoint_auth_methods_supported": ["none"],
70+
"authorization_response_iss_parameter_supported": True,
71+
}
72+
73+
74+
def _is_loopback(host):
75+
if host == "localhost":
76+
return True
77+
try:
78+
return ipaddress.ip_address(host.strip("[]")).is_loopback
79+
except ValueError:
80+
return False
81+
82+
83+
def redirect_uri_error(uri):
84+
"""Why a redirect URI can't be registered, or None."""
85+
if not isinstance(uri, str) or not uri or len(uri) > MAX_URI_LENGTH:
86+
return "each redirect URI must be a non-empty string"
87+
try:
88+
parts = urlsplit(uri)
89+
hostname = parts.hostname
90+
except ValueError:
91+
return f"not a URL: {uri}"
92+
if parts.fragment:
93+
return "a redirect URI can't have a fragment"
94+
if not hostname:
95+
return f"not a URL: {uri}"
96+
if parts.scheme == "https":
97+
return None
98+
if parts.scheme == "http" and _is_loopback(hostname):
99+
return None
100+
return "redirect URIs must be https, or http to localhost"
101+
102+
103+
def _without_port(uri):
104+
parts = urlsplit(uri)
105+
return urlunsplit((parts.scheme, parts.hostname or "", parts.path, parts.query, ""))
106+
107+
108+
def redirect_uri_allowed(client, uri):
109+
"""Whether a client may be sent back to this URI: exactly one it
110+
registered, or a loopback one that differs only in its port."""
111+
if not isinstance(uri, str):
112+
return False
113+
if uri in client["redirect_uris"]:
114+
return True
115+
try:
116+
parts = urlsplit(uri)
117+
if parts.scheme != "http" or not parts.hostname or not _is_loopback(parts.hostname):
118+
return False
119+
loose = _without_port(uri)
120+
except ValueError:
121+
return False
122+
return any(
123+
urlsplit(registered).scheme == "http" and _without_port(registered) == loose
124+
for registered in client["redirect_uris"]
125+
)
126+
127+
128+
def redirect_target(uri, params):
129+
"""The redirect URI with params added to its query."""
130+
parts = urlsplit(uri)
131+
query = parts.query + ("&" if parts.query else "") + urlencode(params)
132+
return urlunsplit((parts.scheme, parts.netloc, parts.path, query, ""))
133+
134+
135+
def redirect_origin(uri):
136+
"""scheme://host[:port] of a URI, for the consent page's form-action."""
137+
parts = urlsplit(uri)
138+
return f"{parts.scheme}://{parts.netloc}"
139+
140+
141+
def pkce_matches(verifier, challenge):
142+
"""RFC 7636 S256: BASE64URL(SHA256(verifier)) == challenge."""
143+
if not isinstance(verifier, str) or not 43 <= len(verifier) <= 128:
144+
return False
145+
digest = hashlib.sha256(verifier.encode("ascii", "replace")).digest()
146+
expected = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
147+
return hmac.compare_digest(expected, challenge)
148+
149+
150+
def resource_ok(resource):
151+
"""A resource indicator (RFC 8707), if the client sent one, must name
152+
this server's /mcp."""
153+
if resource in (None, ""):
154+
return True
155+
return resource.rstrip("/") == resource_url()

0 commit comments

Comments
 (0)