feat(ai): DeviceFlowTokenProvider — completes MCP device flow (AI-050b)#345
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
IMcpTokenProvider→Task<TokenResult> GetTokenAsync(ct)withAuthorized(token) | Pending(verificationUri, userCode) | Failed(msg).TextStackApiClient.AuthorizedRequestAsyncawaits it;Pending/Failed→McpUnauthorizedException(now carrying uri+code) → the catalog's shared wrapper renders an actionableIsError: "open {uri} and enter code {user_code} to connect TextStack, then retry."DeviceFlowTokenProvider— non-blockingexpdecode (not a security boundary — the API validates the signature on use; just decides refresh) →Authorized.POST /auth/refresh-mobile(rotates the refresh token, re-caches) →Authorized; on 401 fall through.POST /auth/device/code, prompt on stderr, spawn a background poll of/auth/device/tokenatintervaluntil approved (cache) / expired / denied — and returnPendingimmediately so the tool call never hangs. Self-heals viafinally → ClearInFlightso a later call re-initiates.Token cache
~/.textstack/mcp-token.json(XDG-aware,TEXTSTACK_MCP_TOKEN_CACHEoverride).0600set before the secret is written; group/world-readable files refused on read.Mode
TEXTSTACK_MCP_TOKENset →StaticEnvTokenProvider(CI escape hatch); elseDeviceFlowTokenProvider. stdout stays JSON-RPC only — the device prompt goes through the stderr-routed logger.Hardened per adversarial QA (0 P1)
/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 samePendingcode (DeviceCodeCount == 1test).WaitAsync(ct)without killing the shared background poll (CancellationToken.Nonelifetime);locknever spansawait.exp/2-segment JWTs treated as expired, never thrown, never returned asAuthorized.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
IToolleaked.Verify
dotnet test tests/TextStack.UnitTests→ 561 passdotnet build/dotnet format --verify-no-changes→ cleanUnlocks AI-048b (
save_highlightwrite) on a per-user consented token.🤖 Generated with Claude Code