Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@
"type": "stdio",
"command": "node",
"args": [
"ts-packages/quarto-hub-mcp/dist/index.js",
"--server",
"wss://quarto-hub.com/ws"
"ts-packages/quarto-hub-mcp/dist/index.js"
]
}
}
Expand Down
89 changes: 55 additions & 34 deletions claude-notes/instructions/hub-mcp-operator-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,22 @@ end users. Sits alongside the SPA OAuth registration in
both clients live in the same Google Cloud project.

Design context:
[`claude-notes/plans/2026-05-05-hub-mcp-device-flow-implementation.md`](../plans/2026-05-05-hub-mcp-device-flow-implementation.md).
[`claude-notes/plans/2026-05-28-hub-mcp-loopback-pkce.md`](../plans/2026-05-28-hub-mcp-loopback-pkce.md).

## What you're registering

`quarto-hub-mcp` authenticates to the hub via Google's OAuth 2.0
device-authorization grant (RFC 8628). That requires a second Google
OAuth client of type **"TV and Limited Input devices"** in the same
Google Cloud project as the SPA's existing "Web application" client.
The hub accepts ID tokens from either audience.
Authorization Code grant with PKCE and a loopback redirect (RFC 8252).
That requires a second Google OAuth client of type **"Desktop app"**
in the same Google Cloud project as the SPA's existing "Web
application" client. The hub accepts ID tokens from either audience.

## Step 1 — register the OAuth client

1. Open <https://console.cloud.google.com/apis/credentials> on the
project that already hosts the SPA OAuth client.
2. **Create Credentials → OAuth client ID**.
3. **Application type → "TV and Limited Input devices"**.
3. **Application type → "Desktop app"**.
4. Name it something the audit log will read clearly, e.g.
`quarto-hub-mcp`.
5. Click **Create**. Copy the **client_id** and **client_secret** off
Expand All @@ -31,10 +31,14 @@ Notes:

- The OAuth consent screen, brand assets, and verified scopes are
shared with the SPA client. No extra consent-screen submission.
- The **client_secret is mandatory** for this client type — Google
refuses `/token` calls without it. This is empirically confirmed
(2026-05-19 verification log in the implementation plan); not a
configuration choice the operator can opt out of.
- The Desktop-app client also issues a **client_secret**, and Google
requires it on the token exchange and the refresh-token grant; PKCE
is layered on top of the confidential-client flow, not a replacement
for the secret. Google documents the installed-app secret as "not
treated as a secret," but it must still be distributed and set in the
env. No bundled default ships in the npm package for v1 — operators
(including the canonical-hub operator) publish both values to end
users.

## Step 2 — configure the hub

Expand Down Expand Up @@ -100,13 +104,21 @@ their first agent action triggers the documented flow:

1. The MCP server probes the hub. Hub returns 401 (no creds).
2. hub-mcp surfaces `AuthRequired`, prompting the agent to call the
`authenticate_start` MCP tool.
3. Tool response carries `https://www.google.com/device`, a
short-lived `user_code`, and a hint URL from Google's response.
4. User opens the URL, enters the code, approves consent.
5. Agent calls `authenticate_finish`. The bundle is persisted to the
user's OS keyring under service `dev.quarto.hub-mcp`, account
`https://accounts.google.com:<mcp-client-id>`.
`authenticate` MCP tool.
3. The tool binds a `127.0.0.1` listener and opens the user's browser
to Google's sign-in page (also printing the URL for headless/SSH
users).
4. User signs in and approves consent; the redirect lands on the local
listener.
5. The tool exchanges the authorization code (PKCE verifier +
client_secret) and persists the bundle to the user's OS keyring
under service `dev.quarto.hub-mcp`, account
`https://accounts.google.com:<mcp-client-id>`. On success the agent
sees `"Authenticated as <email>."` and retries the original action.

Users on headless or remote machines forward the loopback port over
SSH — see the package README's "Headless / SSH sessions" section
(`quarto-hub-mcp --redirect-port N` + `ssh -L N:127.0.0.1:N`).

Subsequent connects refresh the ID token automatically and reuse the
keyring entry until the user revokes the grant.
Expand Down Expand Up @@ -141,19 +153,20 @@ CI logs, etc.):
end user picks up the new value the next time their MCP client
restarts (env vars are read at process start).
3. Existing user keyring entries are **not** invalidated — they hold
ID + refresh tokens that were issued by the old secret but are
redeemable against the new one (Google authenticates the
*device_code* per flow, not the secret directly). No user-side
action required after rotation unless an existing refresh token
itself is also compromised, in which case ask affected users to
revoke the grant (see below).
ID + refresh tokens that were issued by the old secret but remain
redeemable against the new one (the refresh grant authenticates with
whatever secret is currently configured). No user-side action
required after rotation unless an existing refresh token itself is
also compromised, in which case ask affected users to revoke the
grant (see below).

If a **user's** refresh token leaks (rather than the operator's
secret), the user revokes the grant at
<https://myaccount.google.com/permissions> → "Third-party apps with
account access" → the hub-mcp client → "Remove Access". Their next
agent action surfaces `ReauthRequired` and runs through the device
flow again with a new bundle.
secret), the quickest fix is `authenticate_clear`, which best-effort
revokes the refresh token at Google before clearing the local copy.
The manual equivalent is <https://myaccount.google.com/permissions> →
"Third-party apps with account access" → the hub-mcp client → "Remove
Access". Their next agent action surfaces `ReauthRequired` and runs
through the loopback sign-in again with a new bundle.

## Residual risk to communicate to operators

Expand All @@ -162,12 +175,20 @@ flow again with a new bundle.
consult Google on each request. Closing this window requires a
hub-side `sub_denylist` — not in v1; tracked in the plan's
"Future work" section.
- **Refresh tokens are not rotated** by Google for this client type
(empirically confirmed 2026-05-19, 3/3 refreshes omitted the
field). A stolen refresh token is an indefinite foothold until
the user revokes the grant.
- **Headless Linux without Secret Service / libsecret cannot run
hub-mcp.** The credential store refuses silent fallback to a
- **A stolen refresh token is an indefinite foothold** until the user
revokes the grant (via `authenticate_clear` or
myaccount.google.com). hub-mcp persists a `refresh_token` only when
Google returns one and keeps the prior value otherwise, so rotation
behaviour does not change the residual; whichever value is current is
the one to revoke. (The Desktop-app client's exact rotation behaviour
is pending confirmation by the loopback+PKCE plan's Spike A.)
- **The loopback redirect closes the remote no-malware phishing class**
that device flow enabled: tokens are delivered only to the user's own
`127.0.0.1` listener, and PKCE binds the code to a verifier held in
the hub-mcp process. An attacker needs local code execution to
capture tokens — at which point the keyring is already exposed.
- **Headless Linux without Secret Service / libsecret cannot persist
credentials.** The credential store refuses silent fallback to a
plaintext file. Users on those hosts use the SPA cookie path.

See the implementation plan's *Threat model* and *Residual risks
Expand Down
Loading
Loading