Skip to content

feat(ai): DeviceFlowTokenProvider — completes MCP device flow (AI-050b)#345

Merged
mrviduus merged 1 commit into
mainfrom
ai-050b-device-provider
Jun 16, 2026
Merged

feat(ai): DeviceFlowTokenProvider — completes MCP device flow (AI-050b)#345
mrviduus merged 1 commit into
mainfrom
ai-050b-device-provider

Conversation

@mrviduus

Copy link
Copy Markdown
Owner

AI-050b — DeviceFlowTokenProvider (Phase 8)

The CLI side of AI-050a: the MCP server now obtains a per-user JWT via the device grant instead of the AI-048a static shared token. Completes the device flow end to end.

Async token-provider interface

IMcpTokenProviderTask<TokenResult> GetTokenAsync(ct) with Authorized(token) | Pending(verificationUri, userCode) | Failed(msg). TextStackApiClient.AuthorizedRequestAsync awaits it; Pending/FailedMcpUnauthorizedException (now carrying uri+code) → the catalog's shared wrapper renders an actionable IsError: "open {uri} and enter code {user_code} to connect TextStack, then retry."

DeviceFlowTokenProvider — non-blocking

  1. Cached access token, local JWT-exp decode (not a security boundary — the API validates the signature on use; just decides refresh) → Authorized.
  2. Else cached refresh token → body-based POST /auth/refresh-mobile (rotates the refresh token, re-caches) → Authorized; on 401 fall through.
  3. Else start the device flow: POST /auth/device/code, prompt on stderr, spawn a background poll of /auth/device/token at interval until approved (cache) / expired / denied — and return Pending immediately so the tool call never hangs. Self-heals via finally → ClearInFlight so a later call re-initiates.

Token cache

~/.textstack/mcp-token.json (XDG-aware, TEXTSTACK_MCP_TOKEN_CACHE override). 0600 set before the secret is written; group/world-readable files refused on read.

Mode

TEXTSTACK_MCP_TOKEN set → StaticEnvTokenProvider (CI escape hatch); else DeviceFlowTokenProvider. stdout stays JSON-RPC only — the device prompt goes through the stderr-routed logger.

Hardened per adversarial QA (0 P1)

  • Single-flight on the device-code request: concurrent first-callers previously each fired their own /auth/device/code (orphaning server codes). Now the claim is taken under the lock before the POST; all concurrent callers share ONE request and return the same Pending code (DeviceCodeCount == 1 test).
  • A failed code POST clears the slot (reference-guarded) → retryable, never wedged.
  • Genuine caller cancellation propagates via WaitAsync(ct) without killing the shared background poll (CancellationToken.None lifetime); lock never spans await.
  • Refresh-token rotation cached; malformed/no-exp/2-segment JWTs treated as expired, never thrown, never returned as Authorized.

Tests — 29 MCP device-flow/cache (full unit suite 561)

Non-blocking timing assert, single-flight concurrency (20 callers → 1 flow), refresh + rotation + 401-fallthrough, exp-decode edge cases, 0600 + world-readable refusal, recovery-on-denied/expired, cancellation propagation. StudyBuddy set-equality green; no ITool leaked.

Verify

  • dotnet test tests/TextStack.UnitTests → 561 pass
  • dotnet build / dotnet format --verify-no-changes → clean

Unlocks AI-048b (save_highlight write) on a per-user consented token.

🤖 Generated with Claude Code

The CLI side of AI-050a: the MCP server now obtains a per-user JWT via
the device grant instead of a static shared token.

- IMcpTokenProvider goes async: Task<TokenResult> GetTokenAsync with
  Authorized | Pending(verificationUri, userCode) | Failed. AuthorizedRequest
  awaits it; Pending/Failed → McpUnauthorizedException → the catalog renders
  an actionable IsError ('open {uri} and enter code {user_code}, then retry').
- DeviceFlowTokenProvider: local JWT-exp check → body-based refresh via
  /auth/refresh-mobile (rotates the refresh token) → single-flight device
  flow. First user-scoped call with no creds kicks off /auth/device/code in
  the BACKGROUND (stderr prompt, poll loop at interval until approved/
  expired/denied) and returns Pending IMMEDIATELY — never blocks the tool
  call. Self-heals (finally → ClearInFlight) so a later call re-initiates.
- TokenCache ~/.textstack/mcp-token.json (XDG-aware, env override), 0600
  set BEFORE the secret is written; group/world-readable files refused on
  read.
- Mode: TEXTSTACK_MCP_TOKEN set → StaticEnvTokenProvider (CI escape hatch);
  else DeviceFlowTokenProvider. stdout stays JSON-RPC only (prompt → stderr).

QA fixes: single-flight claimed on the device-code REQUEST (concurrent
first-callers issue exactly ONE /auth/device/code and share the same
Pending code — was orphaning server codes); failed code POST is retryable;
genuine caller cancellation propagates without killing the background poll.

29 MCP device-flow/cache tests (non-blocking timing, single-flight
concurrency, refresh+rotation, exp-decode edge cases, 0600, recovery).
561 unit green; StudyBuddy set-equality green. Unlocks AI-048b (write tool)
on a per-user consented token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mrviduus mrviduus merged commit 00f9c83 into main Jun 16, 2026
5 checks passed
@mrviduus mrviduus deleted the ai-050b-device-provider branch June 16, 2026 20:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant