Goal
Make a paired MCP connection work indefinitely without re-pairing, by silently auto-refreshing the short-lived scoped gateway JWT when it expires.
Model
The fula-mcp crate holds a long-lived connection refresh token (delivered in the capability bundle, populated by FxFiles in a later phase, L1d). When a gateway op is rejected with an auth error (the short JWT expired), the MCP:
- POSTs to pinning-webui
{refresh_url} (= /api/mcp/tokens/refresh-connection) with { refresh_token: <token> },
- parses the fresh scoped JWT from the response field
token (the L1a contract returns { token, jti, expiresAt }),
- swaps it into the in-memory bundle, and
- retries the operation exactly ONCE.
If the refresh itself 401/403s (the connection was REVOKED, not merely expired) the MCP gives up and surfaces the original error — that is the real disconnect.
Scope (crate crates/fula-mcp)
- Bundle: optional
refresh_token + refresh_url fields (snake_case, defaulted → backward-compatible); derive the refresh URL from storage_api_url if refresh_url absent; validate HTTPS-or-loopback.
- Swappable JWT:
jwt: RwLock<String> + set_jwt.
- Refresh HTTP helper:
refresh_connection_jwt(...) (5s timeout, mirrors the quota pre-check pattern); parse field token; distinguish revoked (401/403) vs transport vs missing-token. Never log secrets.
- Refresh-on-auth-error retry in the
store/read/list/tags ops via one shared with_refresh_retry helper: detect a gateway auth rejection (S3Error.code ∈ {Unauthorized, AccessDenied, InvalidToken} — the gateway's 401/403 auth codes), refresh, rebuild client, retry once.
Invariants
- Backward-compatible: no
refresh_token in the bundle → behavior byte-identical to today (no retry).
- Single retry only — never loop.
- Secrets (refresh_token + JWTs) never logged.
Runtime dependency
L1c's runtime needs the L1a gateway endpoint POST /api/mcp/tokens/refresh-connection (pinning-webui) live. The bundle fields are optional/defaulted, so this phase is standalone + testable; deploy ordering: merge/deploy L1a/L1b before L1c is used in anger.
PR targets feat/fula-mcp (not main).
Goal
Make a paired MCP connection work indefinitely without re-pairing, by silently auto-refreshing the short-lived scoped gateway JWT when it expires.
Model
The
fula-mcpcrate holds a long-lived connection refresh token (delivered in the capability bundle, populated by FxFiles in a later phase, L1d). When a gateway op is rejected with an auth error (the short JWT expired), the MCP:{refresh_url}(=/api/mcp/tokens/refresh-connection) with{ refresh_token: <token> },token(the L1a contract returns{ token, jti, expiresAt }),If the refresh itself 401/403s (the connection was REVOKED, not merely expired) the MCP gives up and surfaces the original error — that is the real disconnect.
Scope (crate
crates/fula-mcp)refresh_token+refresh_urlfields (snake_case, defaulted → backward-compatible); derive the refresh URL fromstorage_api_urlifrefresh_urlabsent; validate HTTPS-or-loopback.jwt: RwLock<String>+set_jwt.refresh_connection_jwt(...)(5s timeout, mirrors the quota pre-check pattern); parse fieldtoken; distinguish revoked (401/403) vs transport vs missing-token. Never log secrets.store/read/list/tagsops via one sharedwith_refresh_retryhelper: detect a gateway auth rejection (S3Error.code ∈ {Unauthorized, AccessDenied, InvalidToken}— the gateway's 401/403 auth codes), refresh, rebuild client, retry once.Invariants
refresh_tokenin the bundle → behavior byte-identical to today (no retry).Runtime dependency
L1c's runtime needs the L1a gateway endpoint
POST /api/mcp/tokens/refresh-connection(pinning-webui) live. The bundle fields are optional/defaulted, so this phase is standalone + testable; deploy ordering: merge/deploy L1a/L1b before L1c is used in anger.PR targets
feat/fula-mcp(not main).