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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

## [Unreleased]

### Phase 8 — MCP device-flow token provider (AI-050b) (2026-06-16)

CLI-side completion of the device flow built in AI-050a: the headless MCP bridge now obtains a per-user TextStack JWT on its own — no `TEXTSTACK_MCP_TOKEN` needed. All in `backend/src/Ai/TextStack.Ai.Mcp/` + its unit tests.

- **`IMcpTokenProvider` is now async + three-valued.** Replaced the sync `string? GetToken()` with `Task<TokenResult> GetTokenAsync(CancellationToken)`, where `TokenResult` is a closed hierarchy `Authorized(accessToken) | Pending(verificationUri, userCode) | Failed(message)`. `TextStackApiClient.AuthorizedRequest` became async: `Authorized` → attach Bearer; `Pending`/`Failed` → throw `McpUnauthorizedException` (now carries the verification URL + user code, or the failure message). The catalog's `InvokeAsync` catch renders an **actionable** `IsError` — Pending: `"authentication required — open {uri} and enter code {user_code} to connect TextStack, then retry."`; Failed: the reason. `StaticEnvTokenProvider` now returns `Authorized(token)` or `Failed("no TEXTSTACK_MCP_TOKEN configured")`.
- **`DeviceFlowTokenProvider` (non-blocking).** Singleton with its own `HttpClient` (base `TEXTSTACK_API_URL`, Host header pinned, 15s timeout). `GetTokenAsync`: (1) cached access token still valid → `Authorized` (expiry decided by a **local JWT `exp` decode** — base64url-decode the payload, read the claim, NO signature validation; unparseable = expired); (2) else cached refresh token → **body-based** `POST /auth/refresh-mobile` `{ refreshToken }` → cache + `Authorized`, falling through on 401/failure; (3) else start a device flow (single-flight) — `POST /auth/device/code`, log the verification URL + code to **stderr**, spawn a BACKGROUND poll of `POST /auth/device/token` honoring `authorization_pending` (keep polling), `slow_down` (back off), `expired_token`/`access_denied` (clear in-flight so a later call re-initiates), and success (cache tokens), then **return `Pending` IMMEDIATELY** — the tool call never blocks on the browser approval. Concurrent calls share one flow (lock-guarded in-flight + cache state); transport blips during polling are swallowed until the device code's own `expires_in` deadline.
- **QA fix (P2) — single-flight on the `/auth/device/code` POST itself.** The in-flight slot was claimed AFTER the device-code POST, so N concurrent first-callers (cold cache) each fired their OWN `POST /auth/device/code`, orphaning server-side device codes (state leak + wasted round-trips). The provider now holds a `Task<DeviceCodeResponse>? _deviceCodeRequest` claimed **under the lock BEFORE** the network call (lock never spans the `await`): the winner owns the shared request; concurrent callers join it and all return the **same** `Pending` (same `verification_uri` + `user_code`) — the real code, not a half-state. The shared POST runs on `CancellationToken.None` so one caller's cancellation can't tear it down (a cancelled caller stops `await`-ing via `WaitAsync(ct)` and propagates). A FAILED device-code POST clears the slot (guarded by reference-equality so a newer request isn't clobbered) so a later cold call **retries** instead of wedging on a faulted task; terminal/expiry/success funnel through `ClearInFlight`, which now clears `_deviceCodeRequest` too for a clean re-initiate.
- **`TokenCache` (0600).** Persists `{ accessToken, refreshToken, accessExpiresAt }` at `$XDG_CONFIG_HOME/textstack/mcp-token.json` (→ `~/.textstack/mcp-token.json`, override `TEXTSTACK_MCP_TOKEN_CACHE`), creating the dir as needed. Writes with `0600` on Unix (created owner-only BEFORE the secret is written); on read, a **group/world-readable** file is refused and ignored (treated as no cache → re-auth) so a leaked secret is never trusted. Windows relies on the user-profile ACL.
- **Bridge wiring (one branch).** `TEXTSTACK_MCP_TOKEN` set → `StaticEnvTokenProvider` (CI / escape hatch); else → `DeviceFlowTokenProvider` (the default) with a named auth `HttpClient` + the `TokenCache`. stdout stays JSON-RPC only — the device-flow instructions go to stderr via the SDK's stderr-routed logger.
- **Tests**: 25 unit tests (fake `HttpMessageHandler`, no network, temp-dir cache) — first call returns `Pending` without blocking; background poll (pending ×2 → 200) caches → later call `Authorized`; expired-access + valid-refresh → refresh request shape → `Authorized`; refresh 401 → fresh device flow; local JWT exp decode (future/past/garbage/no-exp); cache round-trip + `0600` perms + world-readable-ignored + path resolution; async `StaticEnvTokenProvider`; catalog rendering of `Authorized`/`Pending`/`Failed` for a user-scoped tool. **Single-flight (P2)**: 20 concurrent first-callers issue **exactly ONE** `/auth/device/code` and all get the **same** `Pending` code (`DeviceCodeCount == 1`); a caller arriving **while the POST is mid-flight** (gated handler) joins the one request and gets the real code; a **failed** device-code POST clears the slot so the next call retries (issues a fresh code). Existing AI-047/048a read-tool tests migrated to the async provider. No `ITool` added (StudyBuddy set-equality stays green).

### Phase 8 — Device Authorization Grant backend (AI-050a) (2026-06-16)

Backend for the OAuth 2.0 **Device Authorization Grant (RFC 8628)** so the headless MCP CLI (AI-050b) can obtain a per-user TextStack JWT without a browser redirect. This is the BACKEND slice; the consent page lives in `apps/web` (built concurrently by the frontend agent).
Expand Down
Loading
Loading